integration.rs (18519B)
1 /* 2 * This file is part of LibEuFin. 3 * Copyright (C) 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 use std::time::Duration; 21 22 use anyhow::anyhow; 23 use axum::http::Method; 24 use http_client::client; 25 use jiff::Timestamp; 26 use libeufin_bank::api::{ 27 account::rand_iban_payto, conversion::ConversionResponse, test::BankClient, 28 tx::BankAccountTransactionsResponse, 29 }; 30 use libeufin_ebics::iso20022::model::{InId, InTx}; 31 use libeufin_nexus::{ 32 config::{AccountType, NexusIngestCfg}, 33 fetch::register_incoming, 34 }; 35 use sqlx::postgres::{PgConnectOptions, PgRow}; 36 use taler_api::db::TypeHelper; 37 use taler_common::{ 38 api::{ 39 EddsaPublicKey, HashCode, ShortHashCode, 40 wire::{IncomingBankTransaction, IncomingHistory}, 41 }, 42 config::Config, 43 db::pool, 44 types::{ 45 amount::{Amount, Currency, amount}, 46 payto::{FullIbanPayto, PaytoImpl}, 47 }, 48 }; 49 use taler_macros::db_test; 50 use taler_test_utils::{cli::clap_parse, json, server::TestServer}; 51 use tokio::try_join; 52 53 async fn nexus_cmd(cfg: &Config, cmd: &str) { 54 let tmp: libeufin_nexus::Args = clap_parse(cmd).unwrap(); 55 56 libeufin_nexus::run(cfg, tmp.cmd).await.unwrap() 57 } 58 59 async fn bank_cmd(cfg: &Config, cmd: &str) { 60 let tmp: libeufin_bank::Args = clap_parse(cmd).unwrap(); 61 libeufin_bank::run(cfg, tmp.cmd).await.unwrap(); 62 } 63 64 pub async fn wait_for_http(url: &str) { 65 let client = client(); 66 67 for _ in 0..100 { 68 if http_client::builder::Req::new(&client, Method::GET, &url.parse().unwrap(), "") 69 .send() 70 .await 71 .is_ok() 72 { 73 return; 74 } 75 tokio::time::sleep(Duration::from_millis(10)).await; 76 } 77 panic!("{url} never becomed active") 78 } 79 80 #[db_test(raw)] 81 async fn mini(db: PgConnectOptions) { 82 let bank_cfg = Config::from_file_override( 83 libeufin_bank::constants::CONFIG_SOURCE, 84 Some("conf/mini.conf"), 85 &format!( 86 " 87 [libeufin-bankdb-postgres] 88 CONFIG = postgresql:///{} 89 ", 90 db.get_database().unwrap() 91 ), 92 ) 93 .unwrap(); 94 let nexus_cfg = Config::from_file_override( 95 libeufin_nexus::constants::CONFIG_SOURCE, 96 Some("conf/mini.conf"), 97 &format!( 98 " 99 [libeufin-nexusdb-postgres] 100 CONFIG = postgresql:///{} 101 ", 102 db.get_database().unwrap() 103 ), 104 ) 105 .unwrap(); 106 107 bank_cmd(&bank_cfg, "dbinit -r").await; 108 bank_cmd(&bank_cfg, "passwd admin admin-password").await; 109 bank_cmd(&bank_cfg, "dbinit").await; // Idempotent 110 111 // Check bank is running 112 try_join!( 113 async { 114 bank_cmd(&bank_cfg, "serve").await; 115 Ok(()) 116 }, 117 async { 118 wait_for_http("http://localhost:8080/config").await; 119 Err::<(), _>(anyhow!("done")) 120 } 121 ) 122 .unwrap_err(); 123 // Check nexus is running 124 try_join!( 125 async { 126 nexus_cmd(&nexus_cfg, "serve").await; 127 Ok(()) 128 }, 129 async { 130 wait_for_http("http://localhost:8080/config").await; 131 Err::<(), _>(anyhow!("done")) 132 } 133 ) 134 .unwrap_err(); 135 136 bank_cmd(&bank_cfg, "gc").await; 137 } 138 139 #[db_test(raw)] 140 async fn errors(db: PgConnectOptions) { 141 let bank_cfg = Config::from_file_override( 142 libeufin_bank::constants::CONFIG_SOURCE, 143 Some("conf/integration.conf"), 144 &format!( 145 " 146 [libeufin-bankdb-postgres] 147 CONFIG = postgresql:///{db} 148 ", 149 db = db.get_database().unwrap() 150 ), 151 ) 152 .unwrap(); 153 154 let nexus_cfg = Config::from_file_override( 155 libeufin_nexus::constants::CONFIG_SOURCE, 156 Some("conf/integration.conf"), 157 &format!( 158 " 159 [libeufin-nexusdb-postgres] 160 CONFIG = postgresql:///{db} 161 ", 162 db = db.get_database().unwrap() 163 ), 164 ) 165 .unwrap(); 166 167 nexus_cmd(&nexus_cfg, "dbinit -r").await; 168 bank_cmd(&bank_cfg, "dbinit -r").await; 169 bank_cmd(&bank_cfg, "passwd admin admin-password").await; 170 171 let cfg = NexusIngestCfg::simple(AccountType::exchange, &Currency::KUDOS); 172 let user_payto = rand_iban_payto().full("Sir Florian"); 173 174 let mut ctx = 175 BankClient::new(&bank_cfg, pool(db.clone(), "libeufin_bank").await.unwrap()).await; 176 let db = pool(db.clone(), "libeufin_nexus").await.unwrap(); 177 178 // Load conversion setup manually as the server would refuse to start without an exchange account 179 let mut conn = db.acquire().await.unwrap().detach(); 180 sqlx::raw_sql(include_str!( 181 "../../database-versioning/libeufin-conversion-setup.sql" 182 )) 183 .execute(&mut conn) 184 .await 185 .unwrap(); 186 187 let check_count = async |nb_incoming: i64, nb_bounce: i64, nb_talerable: i64| { 188 assert_eq!( 189 sqlx::query_as::<_, (i64, i64, i64)>( 190 " 191 SELECT (SELECT count(*) FROM incoming_transactions), 192 (SELECT count(*) FROM bounced_transactions), 193 (SELECT count(*) FROM talerable_incoming_transactions) 194 " 195 ) 196 .fetch_one(&db) 197 .await 198 .unwrap(), 199 (nb_incoming, nb_bounce, nb_talerable) 200 ) 201 }; 202 203 let reserve_pub = EddsaPublicKey::rand(); 204 let reserve_payment = InTx { 205 amount: Amount::new(&Currency::KUDOS, 10, 0), 206 debtor: Some(user_payto.as_uri()), 207 subject: Some(format!("Error test {reserve_pub}")), 208 execution_time: Timestamp::now(), 209 id: InId::new(None, Some("reserve_error".into()), None), 210 credit_fee: Amount::zero(&Currency::KUDOS), 211 }; 212 213 // Cashin fails 214 assert_eq!( 215 register_incoming(&db, &cfg, &reserve_payment) 216 .await 217 .unwrap_err() 218 .into_database_error() 219 .unwrap() 220 .message(), 221 "cashin failed: missing exchange account" 222 ); 223 check_count(0, 0, 0).await; 224 225 // But KYC works 226 register_incoming( 227 &db, 228 &cfg, 229 &InTx { 230 id: InId::new(None, Some("KYC".into()), None), 231 subject: Some(format!("Error test KYC:{}", EddsaPublicKey::rand())), 232 ..reserve_payment.clone() 233 }, 234 ) 235 .await 236 .unwrap(); 237 check_count(1, 0, 1).await; 238 239 // Create exchange account 240 bank_cmd( 241 &bank_cfg, 242 "create-account -u exchange -p exchange-password --name 'Mr Money' --exchange", 243 ) 244 .await; 245 ctx.cache_tokens(&["admin", "exchange"]).await; 246 247 // Missing rates 248 register_incoming( 249 &db, 250 &cfg, 251 &InTx { 252 id: InId::new(None, Some("rate_error".into()), None), 253 ..reserve_payment.clone() 254 }, 255 ) 256 .await 257 .unwrap(); 258 check_count(2, 1, 1).await; 259 260 // Set conversion_rate 261 ctx.post_admin("/conversion-info/conversion-rate") 262 .json(json!({ 263 "cashin_ratio": "0.8", 264 "cashin_fee": "KUDOS:0.02", 265 "cashin_tiny_amount": "KUDOS:0.01", 266 "cashin_rounding_mode": "nearest", 267 "cashin_min_amount": "EUR:0", 268 "cashout_ratio": "1.25", 269 "cashout_fee": "EUR:0.003", 270 "cashout_tiny_amount": "EUR:0.01", 271 "cashout_rounding_mode": "zero", 272 "cashout_min_amount": "KUDOS:0.1" 273 })) 274 .await 275 .assert_no_content(); 276 277 // Cashin fails 278 assert_eq!( 279 register_incoming(&db, &cfg, &reserve_payment) 280 .await 281 .unwrap_err() 282 .into_database_error() 283 .unwrap() 284 .message(), 285 "cashin failed: admin balance insufficient" 286 ); 287 check_count(2, 1, 1).await; 288 289 // Allow admin debt 290 bank_cmd(&bank_cfg, "edit-account admin --debit_threshold KUDOS:100").await; 291 292 // Too small amount 293 register_incoming( 294 &db, 295 &cfg, 296 &InTx { 297 amount: Amount::new(&Currency::KUDOS, 0, 10000), 298 ..reserve_payment.clone() 299 }, 300 ) 301 .await 302 .unwrap(); 303 check_count(3, 2, 1).await; 304 305 ctx.geta("/accounts/exchange/transactions") 306 .await 307 .assert_no_content(); 308 309 // Check success 310 let valid_payment = InTx { 311 subject: Some(format!("Success {reserve_pub}")), 312 id: InId::new(None, Some("success".into()), None), 313 ..reserve_payment 314 }; 315 register_incoming(&db, &cfg, &valid_payment).await.unwrap(); 316 check_count(4, 2, 2).await; 317 ctx.geta("/accounts/exchange/transactions") 318 .await 319 .assert_ok(); 320 321 // Check idempotency 322 register_incoming(&db, &cfg, &valid_payment).await.unwrap(); 323 register_incoming( 324 &db, 325 &cfg, 326 &InTx { 327 subject: Some(format!("Success 2 {reserve_pub}")), 328 ..valid_payment 329 }, 330 ) 331 .await 332 .unwrap(); 333 check_count(4, 2, 2).await; 334 } 335 336 #[db_test(raw)] 337 async fn conversion(db: PgConnectOptions) { 338 let bank_cfg = Config::from_file_override( 339 libeufin_bank::constants::CONFIG_SOURCE, 340 Some("conf/integration.conf"), 341 &format!( 342 " 343 [libeufin-bankdb-postgres] 344 CONFIG = postgresql:///{db} 345 ", 346 db = db.get_database().unwrap() 347 ), 348 ) 349 .unwrap(); 350 351 let nexus_cfg = Config::from_file_override( 352 libeufin_nexus::constants::CONFIG_SOURCE, 353 Some("conf/integration.conf"), 354 &format!( 355 " 356 [libeufin-nexusdb-postgres] 357 CONFIG = postgresql:///{db} 358 ", 359 db = db.get_database().unwrap() 360 ), 361 ) 362 .unwrap(); 363 364 let mut ctx = 365 BankClient::new(&bank_cfg, pool(db.clone(), "libeufin_bank").await.unwrap()).await; 366 let db = pool(db.clone(), "libeufin_nexus").await.unwrap(); 367 368 nexus_cmd(&nexus_cfg, "dbinit -r").await; 369 bank_cmd(&bank_cfg, "dbinit -r").await; 370 bank_cmd(&bank_cfg, "passwd admin admin-password").await; 371 bank_cmd(&bank_cfg, "edit-account admin --debit_threshold KUDOS:1000").await; 372 bank_cmd( 373 &bank_cfg, 374 "create-account -u exchange -p exchange-password --name 'Mr Money' --exchange", 375 ) 376 .await; 377 nexus_cmd(&nexus_cfg, "dbinit").await; // Idempotent 378 bank_cmd(&bank_cfg, "dbinit").await; // Idempotent 379 380 // Load conversion setup manually as the server would refuse to start without an exchange account 381 let mut conn = db.acquire().await.unwrap().detach(); 382 sqlx::raw_sql(include_str!( 383 "../../database-versioning/libeufin-conversion-setup.sql" 384 )) 385 .execute(&mut conn) 386 .await 387 .unwrap(); 388 389 ctx.cache_tokens(&["admin", "exchange"]).await; 390 391 let user_payto = rand_iban_payto().full("Sir Christian"); 392 let fiat_payto = rand_iban_payto(); 393 394 // Create user 395 ctx.post_admin("/accounts") 396 .json(json!({ 397 "username": "customer", 398 "password": "customer-password", 399 "name": "John Smith", 400 "internal_payto_uri": user_payto, 401 "cashout_payto_uri": fiat_payto, 402 "debit_threshold": "KUDOS:100", 403 "contact_data": { 404 "phone": "+99" 405 } 406 })) 407 .await 408 .assert_ok(); 409 ctx.cache_tokens(&["customer"]).await; 410 411 // Set conversion rates 412 ctx.post_admin("/conversion-info/conversion-rate") 413 .json(json!({ 414 "cashin_ratio": "0.8", 415 "cashin_fee": "KUDOS:0.02", 416 "cashin_tiny_amount": "KUDOS:0.01", 417 "cashin_rounding_mode": "nearest", 418 "cashin_min_amount": "EUR:0", 419 "cashout_ratio": "1.25", 420 "cashout_fee": "EUR:0.003", 421 "cashout_tiny_amount": "EUR:0.01", 422 "cashout_rounding_mode": "zero", 423 "cashout_min_amount": "KUDOS:0.1" 424 })) 425 .await 426 .assert_no_content(); 427 428 let check_initiated = async |amount: &Amount, name: &str| { 429 sqlx::query( 430 " 431 SELECT amount, credit_payto, subject 432 FROM initiated_outgoing_transactions 433 ORDER BY initiation_time DESC 434 ", 435 ) 436 .try_map(|r: PgRow| { 437 assert_eq!(amount, &r.try_get_amount("amount", &amount.currency)?); 438 let payto = r.try_get_payto("credit_payto")?; 439 assert_eq!(FullIbanPayto::try_from(&payto).unwrap().name, name); 440 Ok(()) 441 }) 442 .fetch_one(&db) 443 .await 444 .unwrap() 445 }; 446 447 // Cashin 448 for i in 0..3 { 449 let key = EddsaPublicKey::rand(); 450 let amount = amount(format!("EUR:{}", 20 + i)); 451 let subject = format!("cashin test {i}: {key}"); 452 nexus_cmd( 453 &nexus_cfg, 454 &format!( 455 "testing fake-incoming --subject \"{subject}\" --amount {amount} {user_payto}" 456 ), 457 ) 458 .await; 459 let converted = ctx 460 .get(format!( 461 "/conversion-info/cashin-rate?amount_debit={amount}" 462 )) 463 .await 464 .assert_ok_json::<ConversionResponse>() 465 .amount_credit; 466 let tx = ctx 467 .geta("/accounts/exchange/transactions") 468 .await 469 .assert_ok_json::<BankAccountTransactionsResponse>() 470 .transactions 471 .remove(0); 472 assert_eq!(tx.subject, subject); 473 assert_eq!(tx.amount, converted); 474 let tx = ctx 475 .geta("/accounts/exchange/taler-wire-gateway/history/incoming") 476 .await 477 .assert_ok_json::<IncomingHistory>() 478 .incoming_transactions 479 .remove(0); 480 assert!( 481 matches!(tx, IncomingBankTransaction::Reserve { amount, reserve_pub, .. } if amount == converted && reserve_pub == key) 482 ); 483 } 484 485 // Cashout 486 for i in 0..3 { 487 let request_uid = ShortHashCode::rand(); 488 let amount = amount(format!("KUDOS:{}", 10 + i)); 489 let converted = ctx.convert(&amount).await; 490 ctx.posta("/accounts/customer/cashouts") 491 .json(json!( { 492 "request_uid" : request_uid, 493 "amount_debit" : amount, 494 "amount_credit" : converted, 495 })) 496 .await 497 .assert_ok(); 498 check_initiated(&converted, "John Smith").await; 499 } 500 501 // Exchange bounce no name 502 for i in 0..3 { 503 let key = EddsaPublicKey::rand(); 504 let amount = amount(format!("EUR:{}", 30 + i)); 505 let subject = format!("cashin test {i}: {key}"); 506 507 // Cashin 508 nexus_cmd( 509 &nexus_cfg, 510 &format!( 511 "testing fake-incoming --subject \"{subject}\" --amount {amount} {user_payto}" 512 ), 513 ) 514 .await; 515 let converted = ctx 516 .get(format!( 517 "/conversion-info/cashin-rate?amount_debit={amount}" 518 )) 519 .await 520 .assert_ok_json::<ConversionResponse>() 521 .amount_credit; 522 let tx = ctx 523 .geta("/accounts/exchange/transactions") 524 .await 525 .assert_ok_json::<BankAccountTransactionsResponse>() 526 .transactions 527 .remove(0); 528 assert_eq!(tx.subject, subject); 529 assert_eq!(tx.amount, converted); 530 let tx = ctx 531 .geta("/accounts/exchange/taler-wire-gateway/history/incoming") 532 .await 533 .assert_ok_json::<IncomingHistory>() 534 .incoming_transactions 535 .remove(0); 536 assert!( 537 matches!(tx, IncomingBankTransaction::Reserve { amount, reserve_pub, .. } if amount == converted && reserve_pub == key) 538 ); 539 540 // Bounce 541 ctx.posta("/accounts/exchange/taler-wire-gateway/transfer") 542 .json(json!({ 543 "request_uid" : HashCode::rand(), 544 "amount" : converted, 545 "exchange_base_url" : "http://exchange.example.com/", 546 "wtid" : key, 547 "credit_account" : "payto://x-taler-bank/localhost/admin", 548 })) 549 .await 550 .assert_ok(); 551 check_initiated(&amount, "Sir Christian").await; 552 } 553 554 // Exchange bounce 555 for i in 0..3 { 556 let key = EddsaPublicKey::rand(); 557 let amount = amount(format!("EUR:{}", 30 + i)); 558 let subject = format!("exchange bounce test {i}: {key}"); 559 560 // Cashin 561 nexus_cmd( 562 &nexus_cfg, 563 &format!( 564 "testing fake-incoming --subject \"{subject}\" --amount {amount} {user_payto}" 565 ), 566 ) 567 .await; 568 let converted = ctx 569 .get(format!( 570 "/conversion-info/cashin-rate?amount_debit={amount}" 571 )) 572 .await 573 .assert_ok_json::<ConversionResponse>() 574 .amount_credit; 575 let tx = ctx 576 .geta("/accounts/exchange/transactions") 577 .await 578 .assert_ok_json::<BankAccountTransactionsResponse>() 579 .transactions 580 .remove(0); 581 assert_eq!(tx.subject, subject); 582 assert_eq!(tx.amount, converted); 583 let tx = ctx 584 .geta("/accounts/exchange/taler-wire-gateway/history/incoming") 585 .await 586 .assert_ok_json::<IncomingHistory>() 587 .incoming_transactions 588 .remove(0); 589 assert!( 590 matches!(tx, IncomingBankTransaction::Reserve { amount, reserve_pub, .. } if amount == converted && reserve_pub == key) 591 ); 592 593 // Bounce 594 ctx.posta("/accounts/exchange/taler-wire-gateway/transfer") 595 .json(json!({ 596 "request_uid" : HashCode::rand(), 597 "amount" : converted, 598 "exchange_base_url" : "http://exchange.example.com/", 599 "wtid" : key, 600 "credit_account" : "payto://x-taler-bank/localhost/admin", 601 })) 602 .await 603 .assert_ok(); 604 check_initiated(&amount, "Sir Christian").await; 605 } 606 }