taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

routine.rs (18874B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2024-2025 Taler Systems SA
      4 
      5   TALER is free software; you can redistribute it and/or modify it under the
      6   terms of the GNU Affero General Public License as published by the Free Software
      7   Foundation; either version 3, or (at your option) any later version.
      8 
      9   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11   A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details.
     12 
     13   You should have received a copy of the GNU Affero General Public License along with
     14   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 */
     16 
     17 use std::{
     18     borrow::Cow,
     19     fmt::Debug,
     20     future::Future,
     21     time::{Duration, Instant},
     22 };
     23 
     24 use axum::Router;
     25 use serde::{Deserialize, de::DeserializeOwned};
     26 use taler_api::db::IncomingType;
     27 use taler_common::{
     28     api_common::{EddsaPublicKey, HashCode, ShortHashCode},
     29     api_params::PageParams,
     30     api_revenue::RevenueIncomingHistory,
     31     api_wire::{
     32         IncomingBankTransaction, IncomingHistory, TransferList, TransferResponse, TransferState,
     33         TransferStatus,
     34     },
     35     error_code::ErrorCode,
     36     types::{amount::amount, base32::Base32, payto::PaytoURI, url},
     37 };
     38 use tokio::time::sleep;
     39 
     40 use crate::{
     41     json,
     42     server::{TestResponse, TestServer as _},
     43 };
     44 
     45 pub async fn routine_pagination<'a, T: DeserializeOwned, F: Future<Output = ()>>(
     46     server: &'a Router,
     47     url: &str,
     48     ids: fn(T) -> Vec<i64>,
     49     mut register: impl FnMut(&'a Router, usize) -> F,
     50 ) {
     51     // Check supported
     52     if !server.get(url).await.is_implemented() {
     53         return;
     54     }
     55 
     56     // Check history is following specs
     57     let assert_history = |args: Cow<'static, str>, size: usize| async move {
     58         let resp = server.get(&format!("{url}?{args}")).await;
     59         assert_history_ids(&resp, ids, size)
     60     };
     61     // Get latest registered id
     62     let latest_id = || async move { assert_history("limit=-1".into(), 1).await[0] };
     63 
     64     for i in 0..20 {
     65         register(server, i).await;
     66     }
     67 
     68     let id = latest_id().await;
     69 
     70     // default
     71     assert_history("".into(), 20).await;
     72 
     73     // forward range
     74     assert_history("limit=10".into(), 10).await;
     75     assert_history("limit=10&offset=4".into(), 10).await;
     76 
     77     // backward range
     78     assert_history("limit=-10".into(), 10).await;
     79     assert_history(format!("limit=-10&{}", id - 4).into(), 10).await;
     80 }
     81 
     82 pub async fn assert_time<R: Debug>(range: std::ops::Range<u128>, task: impl Future<Output = R>) {
     83     let start = Instant::now();
     84     task.await;
     85     let elapsed = start.elapsed().as_millis();
     86     if !range.contains(&elapsed) {
     87         panic!("Expected to last {range:?} got {elapsed:?}")
     88     }
     89 }
     90 
     91 pub async fn routine_history<
     92     'a,
     93     T: DeserializeOwned,
     94     FR: Future<Output = ()>,
     95     FI: Future<Output = ()>,
     96 >(
     97     server: &'a Router,
     98     url: &str,
     99     ids: fn(T) -> Vec<i64>,
    100     nb_register: usize,
    101     mut register: impl FnMut(&'a Router, usize) -> FR,
    102     nb_ignore: usize,
    103     mut ignore: impl FnMut(&'a Router, usize) -> FI,
    104 ) {
    105     // Check history is following specs
    106     macro_rules! assert_history {
    107         ($args:expr, $size:expr) => {
    108             async {
    109                 let resp = server.get(&format!("{url}?{}", $args)).await;
    110                 assert_history_ids(&resp, ids, $size)
    111             }
    112         };
    113     }
    114     // Get latest registered id
    115     let latest_id = || async { assert_history!("limit=-1", 1).await[0] };
    116 
    117     // Check error when no transactions
    118     assert_history!("limit=7".to_owned(), 0).await;
    119 
    120     let mut register_iter = (0..nb_register).peekable();
    121     let mut ignore_iter = (0..nb_ignore).peekable();
    122     while register_iter.peek().is_some() || ignore_iter.peek().is_some() {
    123         if let Some(idx) = register_iter.next() {
    124             register(server, idx).await
    125         }
    126         if let Some(idx) = ignore_iter.next() {
    127             ignore(server, idx).await
    128         }
    129     }
    130     let nb_total = nb_register + nb_ignore;
    131 
    132     // Check ignored
    133     assert_history!(format_args!("limit={nb_total}"), nb_register).await;
    134     // Check skip ignored
    135     assert_history!(format_args!("limit={nb_register}"), nb_register).await;
    136 
    137     // Check no polling when we cannot have more transactions
    138     assert_time(
    139         0..100,
    140         assert_history!(
    141             format_args!("limit=-{}&timeout_ms=1000", nb_register + 1),
    142             nb_register
    143         ),
    144     )
    145     .await;
    146     // Check no polling when already find transactions even if less than delta
    147     assert_time(
    148         0..100,
    149         assert_history!(
    150             format_args!("limit={}&timeout_ms=1000", nb_register + 1),
    151             nb_register
    152         ),
    153     )
    154     .await;
    155 
    156     // Check polling
    157     let id = latest_id().await;
    158     tokio::join!(
    159         // Check polling succeed
    160         assert_time(
    161             100..300,
    162             assert_history!(format_args!("limit=2&offset={id}&timeout_ms=1000"), 1)
    163         ),
    164         assert_time(
    165             200..300,
    166             assert_history!(
    167                 format_args!(
    168                     "limit=1&offset={}&timeout_ms=200",
    169                     id as usize + nb_total * 3
    170                 ),
    171                 0
    172             )
    173         ),
    174         async {
    175             sleep(Duration::from_millis(100)).await;
    176             register(server, 0).await
    177         }
    178     );
    179 
    180     // Test triggers
    181     for i in 0..nb_register {
    182         let id = latest_id().await;
    183         tokio::join!(
    184             // Check polling succeed
    185             assert_time(
    186                 100..300,
    187                 assert_history!(format_args!("limit=7&offset={id}&timeout_ms=1000"), 1)
    188             ),
    189             async {
    190                 sleep(Duration::from_millis(100)).await;
    191                 register(server, i).await
    192             }
    193         );
    194     }
    195 
    196     // Test doesn't trigger
    197     let id = latest_id().await;
    198     tokio::join!(
    199         // Check polling succeed
    200         assert_time(
    201             200..300,
    202             assert_history!(format_args!("limit=7&offset={id}&timeout_ms=200"), 0)
    203         ),
    204         async {
    205             sleep(Duration::from_millis(100)).await;
    206             for i in 0..nb_ignore {
    207                 ignore(server, i).await
    208             }
    209         }
    210     );
    211 
    212     routine_pagination(server, url, ids, register).await;
    213 }
    214 
    215 #[track_caller]
    216 fn assert_history_ids<'de, T: Deserialize<'de>>(
    217     resp: &'de TestResponse,
    218     ids: impl Fn(T) -> Vec<i64>,
    219     size: usize,
    220 ) -> Vec<i64> {
    221     if size == 0 {
    222         resp.assert_no_content();
    223         return vec![];
    224     }
    225     let body = resp.assert_ok_json::<T>();
    226     let history: Vec<_> = ids(body);
    227     let params = resp.query::<PageParams>().check(1024).unwrap();
    228 
    229     // testing the size is like expected
    230     assert_eq!(size, history.len(), "bad history length: {history:?}");
    231     if params.limit < 0 {
    232         // testing that the first id is at most the 'offset' query param.
    233         assert!(
    234             params
    235                 .offset
    236                 .map(|offset| history[0] <= offset)
    237                 .unwrap_or(true),
    238             "bad history offset: {params:?} {history:?}"
    239         );
    240         // testing that the id decreases.
    241         assert!(
    242             history.as_slice().is_sorted_by(|a, b| a > b),
    243             "bad history order: {history:?}"
    244         )
    245     } else {
    246         // testing that the first id is at least the 'offset' query param.
    247         assert!(
    248             params
    249                 .offset
    250                 .map(|offset| history[0] >= offset)
    251                 .unwrap_or(true),
    252             "bad history offset: {params:?} {history:?}"
    253         );
    254         // testing that the id increases.
    255         assert!(
    256             history.as_slice().is_sorted(),
    257             "bad history order: {history:?}"
    258         )
    259     }
    260     history
    261 }
    262 
    263 // Get currency from config
    264 async fn get_currency(server: &Router) -> String {
    265     let config = server
    266         .get("/taler-wire-gateway/config")
    267         .await
    268         .assert_ok_json::<serde_json::Value>();
    269     let currency = config["currency"].as_str().unwrap();
    270     currency.to_owned()
    271 }
    272 
    273 /// Test standard behavior of the transfer endpoints
    274 pub async fn transfer_routine(
    275     server: &Router,
    276     default_status: TransferState,
    277     credit_account: &PaytoURI,
    278 ) {
    279     let currency = &get_currency(server).await;
    280     let default_amount = amount(format!("{currency}:42"));
    281     let transfer_request = json!({
    282         "request_uid": HashCode::rand(),
    283         "amount": default_amount,
    284         "exchange_base_url": "http://exchange.taler/",
    285         "wtid": ShortHashCode::rand(),
    286         "credit_account": credit_account,
    287     });
    288 
    289     // Check empty db
    290     {
    291         server
    292             .get("/taler-wire-gateway/transfers")
    293             .await
    294             .assert_no_content();
    295         server
    296             .get(&format!(
    297                 "/taler-wire-gateway/transfers?status={}",
    298                 default_status.as_ref()
    299             ))
    300             .await
    301             .assert_no_content();
    302     }
    303 
    304     // Check create transfer
    305     {
    306         // Check OK
    307         let first = server
    308             .post("/taler-wire-gateway/transfer")
    309             .json(&transfer_request)
    310             .await
    311             .assert_ok_json::<TransferResponse>();
    312         // Check idempotent
    313         let second = server
    314             .post("/taler-wire-gateway/transfer")
    315             .json(&transfer_request)
    316             .await
    317             .assert_ok_json::<TransferResponse>();
    318         assert_eq!(first.row_id, second.row_id);
    319         assert_eq!(first.timestamp, second.timestamp);
    320 
    321         // Check request uid reuse
    322         server
    323             .post("/taler-wire-gateway/transfer")
    324             .json(&json!(transfer_request + {
    325                 "wtid": ShortHashCode::rand()
    326             }))
    327             .await
    328             .assert_error(ErrorCode::BANK_TRANSFER_REQUEST_UID_REUSED);
    329         // Check wtid reuse
    330         server
    331             .post("/taler-wire-gateway/transfer")
    332             .json(&json!(transfer_request + {
    333                 "request_uid": HashCode::rand(),
    334             }))
    335             .await
    336             .assert_error(ErrorCode::BANK_TRANSFER_WTID_REUSED);
    337 
    338         // Check currency mismatch
    339         server
    340             .post("/taler-wire-gateway/transfer")
    341             .json(&json!(transfer_request + {
    342                 "amount": "BAD:42"
    343             }))
    344             .await
    345             .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH);
    346     }
    347 
    348     // Check transfer by id
    349     {
    350         let wtid = ShortHashCode::rand();
    351         let resp = server
    352             .post("/taler-wire-gateway/transfer")
    353             .json(&json!(transfer_request + {
    354                 "request_uid": HashCode::rand(),
    355                 "wtid": wtid,
    356             }))
    357             .await
    358             .assert_ok_json::<TransferResponse>();
    359 
    360         // Check OK
    361         let tx = server
    362             .get(&format!("/taler-wire-gateway/transfers/{}", resp.row_id))
    363             .await
    364             .assert_ok_json::<TransferStatus>();
    365         assert_eq!(default_status, tx.status);
    366         assert_eq!(default_amount, tx.amount);
    367         assert_eq!("http://exchange.taler/", tx.origin_exchange_url);
    368         assert_eq!(wtid, tx.wtid);
    369         assert_eq!(resp.timestamp, tx.timestamp);
    370 
    371         // Check unknown transaction
    372         server
    373             .get("/taler-wire-gateway/transfers/42")
    374             .await
    375             .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
    376     }
    377 
    378     // Check transfer page
    379     {
    380         for _ in 0..4 {
    381             server
    382                 .post("/taler-wire-gateway/transfer")
    383                 .json(&json!(transfer_request + {
    384                     "request_uid": HashCode::rand(),
    385                     "wtid": ShortHashCode::rand(),
    386                 }))
    387                 .await
    388                 .assert_ok_json::<TransferResponse>();
    389         }
    390         {
    391             let list = server
    392                 .get("/taler-wire-gateway/transfers")
    393                 .await
    394                 .assert_ok_json::<TransferList>();
    395             assert_eq!(list.transfers.len(), 6);
    396             assert_eq!(
    397                 list,
    398                 server
    399                     .get(&format!(
    400                         "/taler-wire-gateway/transfers?status={}",
    401                         default_status.as_ref()
    402                     ))
    403                     .await
    404                     .assert_ok_json::<TransferList>()
    405             )
    406         }
    407 
    408         // Pagination test
    409         routine_pagination::<TransferList, _>(
    410             server,
    411             "/taler-wire-gateway/transfers",
    412             |it| {
    413                 it.transfers
    414                     .into_iter()
    415                     .map(|it| *it.row_id as i64)
    416                     .collect()
    417             },
    418             |server, i| async move {
    419                 server
    420                     .post("/taler-wire-gateway/transfer")
    421                     .json(&json!({
    422                         "request_uid": HashCode::rand(),
    423                         "amount": amount(format!("{currency}:0.0{i}")),
    424                         "exchange_base_url": url("http://exchange.taler"),
    425                         "wtid": ShortHashCode::rand(),
    426                         "credit_account": credit_account,
    427                     }))
    428                     .await
    429                     .assert_ok_json::<TransferResponse>();
    430             },
    431         )
    432         .await;
    433     }
    434 }
    435 
    436 async fn add_incoming_routine(
    437     server: &Router,
    438     currency: &str,
    439     kind: IncomingType,
    440     debit_acount: &PaytoURI,
    441 ) {
    442     let (path, key) = match kind {
    443         IncomingType::reserve => ("/taler-wire-gateway/admin/add-incoming", "reserve_pub"),
    444         IncomingType::kyc => ("/taler-wire-gateway/admin/add-kycauth", "account_pub"),
    445         IncomingType::wad => unreachable!(),
    446     };
    447     let valid_req = json!({
    448         "amount": format!("{currency}:44"),
    449         key: EddsaPublicKey::rand(),
    450         "debit_account": debit_acount,
    451     });
    452 
    453     // Check OK
    454     server.post(path).json(&valid_req).await.assert_ok();
    455 
    456     match kind {
    457         IncomingType::reserve => {
    458             // Trigger conflict due to reused reserve_pub
    459             server
    460                 .post(path)
    461                 .json(&json!(valid_req + {
    462                     "amount": format!("{currency}:44.1"),
    463                 }))
    464                 .await
    465                 .assert_error(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT)
    466         }
    467         IncomingType::kyc => {
    468             // Non conflict on reuse
    469             server.post(path).json(&valid_req).await.assert_ok();
    470         }
    471         IncomingType::wad => unreachable!(),
    472     }
    473 
    474     // Currency mismatch
    475     server
    476         .post(path)
    477         .json(&json!(valid_req + {
    478             "amount": "BAD:33"
    479         }))
    480         .await
    481         .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH);
    482 
    483     // Bad BASE32 reserve_pub
    484     server
    485         .post(path)
    486         .json(&json!(valid_req + {
    487             key: "I love chocolate"
    488         }))
    489         .await
    490         .assert_error(ErrorCode::GENERIC_JSON_INVALID);
    491 
    492     server
    493         .post(path)
    494         .json(&json!(valid_req + {
    495             key: Base32::<31>::rand()
    496         }))
    497         .await
    498         .assert_error(ErrorCode::GENERIC_JSON_INVALID);
    499 
    500     // Bad payto kind
    501     server
    502         .post(path)
    503         .json(&json!(valid_req + {
    504             "debit_account": "http://email@test.com"
    505         }))
    506         .await
    507         .assert_error(ErrorCode::GENERIC_JSON_INVALID);
    508 }
    509 
    510 /// Test standard behavior of the revenue endpoints
    511 pub async fn revenue_routine(server: &Router, debit_acount: &PaytoURI, kyc: bool) {
    512     let currency = &get_currency(server).await;
    513 
    514     routine_history(
    515         server,
    516         "/taler-revenue/history",
    517         |it: RevenueIncomingHistory| {
    518             it.incoming_transactions
    519                 .into_iter()
    520                 .map(|it| *it.row_id as i64)
    521                 .collect()
    522         },
    523         2,
    524         |server, i| async move {
    525             if i % 2 == 0 || !kyc {
    526                 server
    527                     .post("/taler-wire-gateway/admin/add-incoming")
    528                     .json(&json!({
    529                         "amount": format!("{currency}:0.0{i}"),
    530                         "reserve_pub": EddsaPublicKey::rand(),
    531                         "debit_account": debit_acount,
    532                     }))
    533                     .await
    534                     .assert_ok_json::<TransferResponse>();
    535             } else {
    536                 server
    537                     .post("/taler-wire-gateway/admin/add-kycauth")
    538                     .json(&json!({
    539                         "amount": format!("{currency}:0.0{i}"),
    540                         "account_pub": EddsaPublicKey::rand(),
    541                         "debit_account": debit_acount,
    542                     }))
    543                     .await
    544                     .assert_ok_json::<TransferResponse>();
    545             }
    546         },
    547         0,
    548         |_, _| async move {},
    549     )
    550     .await;
    551 }
    552 
    553 /// Test standard behavior of the admin add incoming endpoints
    554 pub async fn admin_add_incoming_routine(server: &Router, debit_acount: &PaytoURI, kyc: bool) {
    555     let currency = &get_currency(server).await;
    556 
    557     // History
    558     // TODO check non taler some are ignored
    559     routine_history(
    560         server,
    561         "/taler-wire-gateway/history/incoming",
    562         |it: IncomingHistory| {
    563             it.incoming_transactions
    564                 .into_iter()
    565                 .map(|it| match it {
    566                     IncomingBankTransaction::Reserve { row_id, .. }
    567                     | IncomingBankTransaction::Wad { row_id, .. }
    568                     | IncomingBankTransaction::Kyc { row_id, .. } => *row_id as i64,
    569                 })
    570                 .collect()
    571         },
    572         2,
    573         |server, i| async move {
    574             if i % 2 == 0 || !kyc {
    575                 server
    576                     .post("/taler-wire-gateway/admin/add-incoming")
    577                     .json(&json!({
    578                         "amount": format!("{currency}:0.0{i}"),
    579                         "reserve_pub": EddsaPublicKey::rand(),
    580                         "debit_account": debit_acount,
    581                     }))
    582                     .await
    583                     .assert_ok_json::<TransferResponse>();
    584             } else {
    585                 server
    586                     .post("/taler-wire-gateway/admin/add-kycauth")
    587                     .json(&json!({
    588                         "amount": format!("{currency}:0.0{i}"),
    589                         "account_pub": EddsaPublicKey::rand(),
    590                         "debit_account": debit_acount,
    591                     }))
    592                     .await
    593                     .assert_ok_json::<TransferResponse>();
    594             }
    595         },
    596         0,
    597         |_, _| async move {},
    598     )
    599     .await;
    600     // Add incoming reserve
    601     add_incoming_routine(server, currency, IncomingType::reserve, debit_acount).await;
    602     if kyc {
    603         // Add incoming kyc
    604         add_incoming_routine(server, currency, IncomingType::kyc, debit_acount).await;
    605     }
    606 }