libeufin

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

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 }