taler-rust

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

cyclos-harness.rs (11591B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 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::{str::FromStr as _, time::Duration};
     18 
     19 use clap::Parser as _;
     20 use jiff::Timestamp;
     21 use owo_colors::OwoColorize as _;
     22 use sqlx::PgPool;
     23 use taler_build::long_version;
     24 use taler_common::{
     25     CommonArgs,
     26     api_common::{EddsaPublicKey, HashCode, ShortHashCode, rand_edsa_pub_key},
     27     api_params::{History, Page},
     28     api_wire::{IncomingBankTransaction, TransferRequest, TransferState},
     29     config::Config,
     30     db::{dbinit, pool},
     31     taler_main,
     32     types::{
     33         amount::{Amount, Currency, Decimal, decimal},
     34         url,
     35     },
     36 };
     37 use taler_cyclos::{
     38     CyclosId, FullCyclosPayto,
     39     config::{AccountType, parse_db_cfg},
     40     constants::CONFIG_SOURCE,
     41     cyclos_api::{api::CyclosAuth, client::Client},
     42     db::{self, TransferResult},
     43     worker::{Worker, WorkerResult},
     44 };
     45 
     46 /// Cyclos Adapter harness test suite
     47 #[derive(clap::Parser, Debug)]
     48 #[command(long_version = long_version(), about, long_about = None)]
     49 struct Args {
     50     #[clap(flatten)]
     51     common: CommonArgs,
     52 
     53     #[command(subcommand)]
     54     cmd: Command,
     55 }
     56 
     57 #[derive(clap::Subcommand, Debug)]
     58 enum Command {
     59     /// Run logic tests
     60     Logic {
     61         #[clap(long, short)]
     62         reset: bool,
     63     },
     64     /// Run online tests
     65     Online {
     66         #[clap(long, short)]
     67         reset: bool,
     68     },
     69 }
     70 
     71 fn step(step: &str) {
     72     println!("{}", step.green());
     73 }
     74 
     75 struct Harness<'a> {
     76     pool: &'a PgPool,
     77     client: Client<'a>,
     78     wire: Client<'a>,
     79     client_id: u64,
     80     wire_id: u64,
     81     currency: Currency,
     82 }
     83 
     84 impl<'a> Harness<'a> {
     85     async fn balance(&self) -> (Decimal, Decimal) {
     86         let (exchange, client) =
     87             tokio::try_join!(self.wire.accounts(), self.client.accounts()).unwrap();
     88         (
     89             exchange[0].status.available_balance,
     90             client[0].status.available_balance,
     91         )
     92     }
     93 
     94     /// Send transaction from client to exchange
     95     async fn client_send(&self, subject: &str, amount: Decimal) {
     96         self.client
     97             .direct_payment(self.wire_id, amount, subject)
     98             .await
     99             .unwrap();
    100     }
    101 
    102     /// Send transaction from exchange to client
    103     async fn exchange_send(&self, subject: &str, amount: Decimal) {
    104         self.wire
    105             .direct_payment(self.client_id, amount, subject)
    106             .await
    107             .unwrap();
    108     }
    109 
    110     /// Run the worker once
    111     async fn worker(&'a self) -> WorkerResult {
    112         let db = &mut self.pool.acquire().await.unwrap().detach();
    113         let account = self.wire.accounts().await.unwrap()[0].clone();
    114         Worker {
    115             db,
    116             currency: Currency::from_str("TEST").unwrap(),
    117             client: &self.wire,
    118             account_type_id: *account.ty.id,
    119             account_type: AccountType::Exchange,
    120         }
    121         .run()
    122         .await
    123     }
    124 
    125     async fn expect_incoming(&self, key: EddsaPublicKey) {
    126         let transfer = db::incoming_history(
    127             self.pool,
    128             &History {
    129                 page: Page {
    130                     limit: -1,
    131                     offset: None,
    132                 },
    133                 timeout_ms: None,
    134             },
    135             &self.currency,
    136             || tokio::sync::watch::channel(0).1,
    137         )
    138         .await
    139         .unwrap();
    140         assert!(matches!(
    141             transfer.first().unwrap(),
    142             IncomingBankTransaction::Reserve { reserve_pub, .. } if *reserve_pub == key,
    143         ));
    144     }
    145 
    146     async fn custom_transfer(&self, amount: Decimal, creditor: &FullCyclosPayto) -> u64 {
    147         let res = db::make_transfer(
    148             self.pool,
    149             &TransferRequest {
    150                 request_uid: HashCode::rand(),
    151                 amount: Amount::new_decimal(&self.currency, amount),
    152                 exchange_base_url: url("https://test.com"),
    153                 wtid: ShortHashCode::rand(),
    154                 credit_account: creditor.as_payto(),
    155             },
    156             creditor,
    157             &Timestamp::now(),
    158         )
    159         .await
    160         .unwrap();
    161         match res {
    162             TransferResult::Success { id, .. } => id,
    163             TransferResult::RequestUidReuse | TransferResult::WtidReuse => unreachable!(),
    164         }
    165     }
    166 
    167     async fn expect_transfer_status(&self, id: u64, status: TransferState, msg: Option<&str>) {
    168         let mut attempts = 0;
    169         loop {
    170             let transfer = db::transfer_by_id(self.pool, id, &self.currency)
    171                 .await
    172                 .unwrap()
    173                 .unwrap();
    174             if (transfer.status, transfer.status_msg.as_deref()) == (status, msg) {
    175                 return;
    176             }
    177             if attempts > 40 {
    178                 assert_eq!(
    179                     (transfer.status, transfer.status_msg.as_deref()),
    180                     (status, msg)
    181                 );
    182             }
    183             attempts += 1;
    184             tokio::time::sleep(Duration::from_millis(200)).await;
    185         }
    186     }
    187 }
    188 
    189 struct Balances<'a> {
    190     client: &'a Harness<'a>,
    191     exchange_balance: Decimal,
    192     client_balance: Decimal,
    193 }
    194 
    195 impl<'a> Balances<'a> {
    196     pub async fn new(client: &'a Harness<'a>) -> Self {
    197         let (exchange_balance, client_balance) = client.balance().await;
    198         Self {
    199             client,
    200             exchange_balance,
    201             client_balance,
    202         }
    203     }
    204 
    205     async fn expect_add(&mut self, diff: Decimal) {
    206         self.exchange_balance = self.exchange_balance.try_add(&diff).unwrap();
    207         self.client_balance = self.client_balance.try_sub(&diff).unwrap();
    208 
    209         let current = self.client.balance().await;
    210         assert_eq!(
    211             current,
    212             (self.exchange_balance, self.client_balance),
    213             "{current:?} {diff}"
    214         );
    215     }
    216 
    217     async fn expect_sub(&mut self, diff: Decimal) {
    218         self.exchange_balance = self.exchange_balance.try_sub(&diff).unwrap();
    219         self.client_balance = self.client_balance.try_add(&diff).unwrap();
    220 
    221         let current = self.client.balance().await;
    222         assert_eq!(
    223             current,
    224             (self.exchange_balance, self.client_balance),
    225             "{current:?} {diff}"
    226         );
    227     }
    228 }
    229 
    230 /// Run logic tests against real Cyclos backend
    231 async fn logic_harness(cfg: &Config, reset: bool) -> anyhow::Result<()> {
    232     step("Run Cyclos logic harness tests");
    233 
    234     step("Prepare db");
    235     let db_cfg = parse_db_cfg(cfg)?;
    236     let pool = pool(db_cfg.cfg, "cyclos").await?;
    237     let mut db = pool.acquire().await?.detach();
    238     dbinit(&mut db, db_cfg.sql_dir.as_ref(), "cyclos", reset).await?;
    239 
    240     let client = reqwest::Client::new();
    241     let api_url = reqwest::Url::from_str("http://localhost:8080/api/").unwrap();
    242     let wire = Client {
    243         client: &client,
    244         api_url: &api_url,
    245         auth: &CyclosAuth::Basic {
    246             username: "wire".into(),
    247             password: "f20n4X3qV44dNoZUmpeU".into(),
    248         },
    249     };
    250     let client = Client {
    251         client: &client,
    252         api_url: &api_url,
    253         auth: &CyclosAuth::Basic {
    254             username: "client".into(),
    255             password: "1EkY5JJMrkwyvv9yK7x4".into(),
    256         },
    257     };
    258     let currency = Currency::from_str("TEST").unwrap();
    259 
    260     let harness = Harness {
    261         pool: &pool,
    262         client_id: *client.whoami().await.unwrap().id,
    263         wire_id: *wire.whoami().await.unwrap().id,
    264         client,
    265         wire,
    266         currency,
    267     };
    268 
    269     step("Warmup");
    270     harness.worker().await.unwrap();
    271 
    272     let now = Timestamp::now();
    273     let balance = &mut Balances::new(&harness).await;
    274 
    275     step("Test incoming talerable transaction");
    276     // Send talerable transaction
    277     let reserve_pub = rand_edsa_pub_key();
    278     let amount = decimal("3.3");
    279     harness
    280         .client_send(&format!("Taler {reserve_pub}"), amount)
    281         .await;
    282     // Sync and register
    283     harness.worker().await?;
    284     harness.expect_incoming(reserve_pub).await;
    285     balance.expect_add(amount).await;
    286 
    287     step("Test incoming malformed transaction");
    288     // Send malformed transaction
    289     let amount = decimal("3.4");
    290     harness
    291         .client_send(&format!("Malformed test {now}"), amount)
    292         .await;
    293     balance.expect_add(amount).await;
    294     // Sync and bounce
    295     harness.worker().await?;
    296     balance.expect_sub(amount).await;
    297 
    298     step("Test transfer transactions");
    299     let amount = decimal("3.5");
    300     // Init a transfer to client
    301     let transfer_id = harness
    302         .custom_transfer(
    303             amount,
    304             &FullCyclosPayto::new(CyclosId(harness.client_id), "Client".to_string()),
    305         )
    306         .await;
    307     // Check transfer pending
    308     harness
    309         .expect_transfer_status(transfer_id, TransferState::pending, None)
    310         .await;
    311     // Should send
    312     harness.worker().await?;
    313     // Wait for transaction to finalize
    314     balance.expect_sub(amount).await;
    315     // Should register
    316     harness.worker().await?;
    317     // Check transfer is now successful
    318     harness
    319         .expect_transfer_status(transfer_id, TransferState::success, None)
    320         .await;
    321 
    322     step("Test transfer to self");
    323     // Init a transfer to self
    324     let transfer_id = harness
    325         .custom_transfer(
    326             decimal("10.1"),
    327             &FullCyclosPayto::new(CyclosId(harness.wire_id), "Self".to_string()),
    328         )
    329         .await;
    330     // Should failed
    331     harness.worker().await?;
    332     // Check transfer failed
    333     harness
    334         .expect_transfer_status(
    335             transfer_id,
    336             TransferState::permanent_failure,
    337             Some("permissionDenied - The operation was denied because a required permission was not granted"),
    338         )
    339         .await;
    340 
    341     step("Test transfer to unknown account");
    342     // Init a transfer to self
    343     let transfer_id = harness
    344         .custom_transfer(
    345             decimal("10.1"),
    346             &FullCyclosPayto::new(CyclosId(42), "Unknown".to_string()),
    347         )
    348         .await;
    349     // Should failed
    350     harness.worker().await?;
    351     // Check transfer failed
    352     harness
    353         .expect_transfer_status(
    354             transfer_id,
    355             TransferState::permanent_failure,
    356             Some("unknown BasicUser 42"),
    357         )
    358         .await;
    359 
    360     step("Test unexpected outgoing");
    361     // Manual tx from the exchange
    362     let amount = decimal("4");
    363     harness
    364         .exchange_send(&format!("What is this ? {now}"), amount)
    365         .await;
    366     harness.worker().await?;
    367     // Wait for transaction to finalize
    368     balance.expect_sub(amount).await;
    369     harness.worker().await?;
    370 
    371     step("Finish");
    372 
    373     Ok(())
    374 }
    375 
    376 /// Run online tests against real Cyclos backend
    377 async fn online_harness(config: &Config, reset: bool) -> anyhow::Result<()> {
    378     step("Run Cyclos harness tests");
    379 
    380     step("Finish");
    381 
    382     Ok(())
    383 }
    384 
    385 fn main() {
    386     let args = Args::parse();
    387     taler_main(CONFIG_SOURCE, args.common, |cfg| async move {
    388         match args.cmd {
    389             Command::Logic { reset } => logic_harness(&cfg, reset).await,
    390             Command::Online { reset } => online_harness(&cfg, reset).await,
    391         }
    392     });
    393 }