magnet-bank-harness.rs (18448B)
1 /* 2 This file is part of TALER 3 Copyright (C) 2025, 2026 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::{fmt::Debug, time::Duration}; 18 19 use aws_lc_rs::signature::EcdsaKeyPair; 20 use clap::Parser as _; 21 use failure_injection::{InjectedErr, set_failure_scenario}; 22 use jiff::{Timestamp, Zoned}; 23 use owo_colors::OwoColorize; 24 use sqlx::PgPool; 25 use taler_api::notification::dummy_listen; 26 use taler_build::long_version; 27 use taler_common::{ 28 CommonArgs, 29 api::{ 30 EddsaPublicKey, HashCode, ShortHashCode, 31 params::{History, Page, Pooling}, 32 wire::{IncomingBankTransaction, TransferState}, 33 }, 34 config::Config, 35 db::{dbinit, pool}, 36 taler_main, 37 types::{amount::decimal, url}, 38 }; 39 use taler_magnet_bank::{ 40 FullHuPayto, HuIban, 41 config::{AccountType, HarnessCfg, parse_db_cfg}, 42 constants::CONFIG_SOURCE, 43 db::{self, TransferResult}, 44 magnet_api::{ 45 client::{ApiClient, AuthClient}, 46 types::{Account, Direction, Order, TxDto, TxStatus}, 47 }, 48 setup::{self, Keys}, 49 worker::{Worker, WorkerError, WorkerResult, run_worker}, 50 }; 51 52 // TODO macro for retry/expect logic 53 54 /// Taler Magnet Bank Adapter harness test suite 55 #[derive(clap::Parser, Debug)] 56 #[command(long_version = long_version(), about, long_about = None)] 57 struct Args { 58 #[clap(flatten)] 59 common: CommonArgs, 60 61 #[command(subcommand)] 62 cmd: Command, 63 } 64 65 #[derive(clap::Subcommand, Debug)] 66 enum Command { 67 /// Run logic tests 68 Logic { 69 #[arg(short, long)] 70 reset: bool, 71 }, 72 /// Run online tests 73 Online { 74 #[arg(short, long)] 75 reset: bool, 76 }, 77 } 78 79 /// Custom client for harness actions 80 struct Harness<'a> { 81 cfg: &'a HarnessCfg, 82 pool: &'a PgPool, 83 api: ApiClient<'a>, 84 exchange: Account, 85 client: Account, 86 signing_key: &'a EcdsaKeyPair, 87 } 88 89 impl<'a> Harness<'a> { 90 async fn new( 91 cfg: &'a HarnessCfg, 92 client: &'a http_client::Client, 93 pool: &'a PgPool, 94 keys: &'a Keys, 95 ) -> Self { 96 let api = AuthClient::new(client, &cfg.worker.api_url, &cfg.worker.consumer) 97 .upgrade(&keys.access_token); 98 let (exchange, client) = tokio::try_join!( 99 api.account(cfg.worker.payto.bban()), 100 api.account(cfg.client_payto.bban()) 101 ) 102 .unwrap(); 103 Self { 104 cfg, 105 pool, 106 api, 107 exchange, 108 client, 109 signing_key: &keys.signing_key, 110 } 111 } 112 113 async fn worker(&'a self) -> WorkerResult { 114 let db = &mut self.pool.acquire().await.unwrap().detach(); 115 Worker { 116 client: &self.api, 117 db, 118 account_number: &self.exchange.number, 119 account_code: self.exchange.code, 120 key: self.signing_key, 121 account_type: AccountType::Exchange, 122 ignore_tx_before: self.cfg.worker.ignore_tx_before, 123 ignore_bounces_before: self.cfg.worker.ignore_bounces_before, 124 } 125 .run() 126 .await 127 } 128 129 async fn balance(&self) -> (u32, u32) { 130 let (exchange_balance, client_balance) = tokio::try_join!( 131 self.api.balance_mini(self.exchange.iban.bban()), 132 self.api.balance_mini(self.client.iban.bban()) 133 ) 134 .unwrap(); 135 ( 136 exchange_balance.balance as u32, 137 client_balance.balance as u32, 138 ) 139 } 140 141 async fn custom_transfer(&self, forint: u32, creditor: FullHuPayto) -> u64 { 142 let res = db::make_transfer( 143 self.pool, 144 &db::Transfer { 145 request_uid: HashCode::rand(), 146 amount: decimal(format!("{forint}")), 147 exchange_base_url: url("https://test.com"), 148 metadata: None, 149 wtid: ShortHashCode::rand(), 150 creditor, 151 }, 152 &Timestamp::now(), 153 ) 154 .await 155 .unwrap(); 156 match res { 157 TransferResult::Success { id, .. } => id, 158 TransferResult::RequestUidReuse | TransferResult::WtidReuse => unreachable!(), 159 } 160 } 161 162 async fn transfer(&self, forint: u32) -> u64 { 163 self.custom_transfer(forint, FullHuPayto::new(self.client.iban.clone(), "Name")) 164 .await 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).await.unwrap().unwrap(); 171 if (transfer.status, transfer.status_msg.as_deref()) == (status, msg) { 172 return; 173 } 174 if attempts > 40 { 175 assert_eq!( 176 (transfer.status, transfer.status_msg.as_deref()), 177 (status, msg) 178 ); 179 } 180 attempts += 1; 181 tokio::time::sleep(Duration::from_millis(200)).await; 182 } 183 } 184 185 async fn expect_incoming(&self, key: EddsaPublicKey) { 186 let transfer = db::incoming_history( 187 self.pool, 188 &History { 189 page: Page { 190 limit: -1, 191 offset: None, 192 }, 193 pooling: Pooling { timeout_ms: None }, 194 }, 195 dummy_listen, 196 ) 197 .await 198 .unwrap(); 199 // TODO revert to assert_matches! once rust-lang#82775 is stable 200 assert!(matches!( 201 transfer.first().unwrap(), 202 IncomingBankTransaction::Reserve { reserve_pub, .. } if *reserve_pub == key, 203 )); 204 } 205 206 /// Send a transaction between two magnet accounts 207 async fn send_tx(&self, from: &Account, to: &HuIban, subject: &str, amount: u32) -> u64 { 208 let now = Zoned::now(); 209 let info = self 210 .api 211 .init_tx( 212 from.code, 213 amount as f64, 214 subject, 215 &now.date(), 216 "Name", 217 to.bban(), 218 ) 219 .await 220 .unwrap(); 221 self.api 222 .submit_tx( 223 self.signing_key, 224 &from.number, 225 info.code, 226 info.amount, 227 &now.date(), 228 to.bban(), 229 ) 230 .await 231 .unwrap(); 232 info.code 233 } 234 235 async fn latest_tx(&self, account: &Account) -> TxDto { 236 self.api 237 .page_tx( 238 Direction::Both, 239 Order::Descending, 240 1, 241 account.iban.bban(), 242 &None, 243 true, 244 ) 245 .await 246 .unwrap() 247 .list 248 .pop() 249 .unwrap() 250 .tx 251 } 252 253 /// Send transaction from client to exchange 254 async fn client_send(&self, subject: &str, amount: u32) -> u64 { 255 self.send_tx(&self.client, &self.exchange.iban, subject, amount) 256 .await 257 } 258 259 /// Send transaction from exchange to client 260 async fn exchange_send_to(&self, subject: &str, amount: u32, to: &HuIban) -> u64 { 261 self.send_tx(&self.exchange, to, subject, amount).await 262 } 263 264 /// Send transaction from exchange to client 265 async fn exchange_send(&self, subject: &str, amount: u32) -> u64 { 266 self.exchange_send_to(subject, amount, &self.client.iban) 267 .await 268 } 269 270 async fn expect_status(&self, code: u64, status: TxStatus) { 271 let mut attempts = 0; 272 loop { 273 let current = self.api.get_tx(code).await.unwrap().status; 274 if current == status { 275 return; 276 } 277 if attempts > 40 { 278 assert_eq!(current, status, "{code}"); 279 } 280 attempts += 1; 281 tokio::time::sleep(Duration::from_millis(200)).await; 282 } 283 } 284 } 285 286 struct Balances<'a> { 287 client: &'a Harness<'a>, 288 exchange_balance: u32, 289 client_balance: u32, 290 } 291 292 impl<'a> Balances<'a> { 293 pub async fn new(client: &'a Harness<'a>) -> Self { 294 let (exchange_balance, client_balance) = client.balance().await; 295 Self { 296 client, 297 exchange_balance, 298 client_balance, 299 } 300 } 301 302 async fn expect(&mut self, diff: i32) { 303 self.exchange_balance = (self.exchange_balance as i32 + diff) as u32; 304 self.client_balance = (self.client_balance as i32 - diff) as u32; 305 let mut attempts = 0; 306 loop { 307 let current = self.client.balance().await; 308 if current == (self.exchange_balance, self.client_balance) { 309 return; 310 } 311 if attempts > 40 { 312 assert_eq!( 313 current, 314 (self.exchange_balance, self.client_balance), 315 "{current:?} {diff}" 316 ); 317 } 318 attempts += 1; 319 tokio::time::sleep(Duration::from_millis(200)).await; 320 } 321 } 322 } 323 324 fn step(step: &str) { 325 println!("{}", step.green()); 326 } 327 328 /// Run logic tests against local Magnet Bank backend 329 async fn logic_harness(cfg: &Config, reset: bool) -> anyhow::Result<()> { 330 step("Run Magnet Bank logic harness tests"); 331 332 step("Prepare db"); 333 let db_cfg = parse_db_cfg(cfg)?; 334 let pool = pool(db_cfg.cfg, "magnet_bank").await?; 335 let mut db = pool.acquire().await?.detach(); 336 dbinit(&mut db, db_cfg.sql_dir.as_ref(), "magnet-bank", reset).await?; 337 338 let cfg = HarnessCfg::parse(cfg)?; 339 let keys = setup::load(&cfg.worker)?; 340 let client = http_client::client(); 341 342 let harness = Harness::new(&cfg, &client, &pool, &keys).await; 343 344 step("Warmup"); 345 harness.worker().await?; 346 tokio::time::sleep(Duration::from_secs(5)).await; 347 harness.worker().await?; 348 349 let unknown_account = 350 FullHuPayto::new(HuIban::from_bban("1620000310991642").unwrap(), "Unknown"); 351 let now = Timestamp::now(); 352 let balance = &mut Balances::new(&harness).await; 353 354 step("Test incoming talerable transaction"); 355 // Send talerable transaction 356 let reserve_pub = EddsaPublicKey::rand(); 357 harness 358 .client_send(&format!("Taler {reserve_pub}"), 33) 359 .await; 360 // Wait for transaction to finalize 361 balance.expect(33).await; 362 // Sync and register 363 harness.worker().await?; 364 harness.expect_incoming(reserve_pub).await; 365 366 step("Test incoming malformed transaction"); 367 // Send malformed transaction 368 harness 369 .client_send(&format!("Malformed test {now}"), 34) 370 .await; 371 // Wait for transaction to finalize 372 balance.expect(34).await; 373 // Sync and bounce 374 harness.worker().await?; 375 // Wait for bounce to finalize 376 balance.expect(-34).await; 377 harness.worker().await?; 378 379 step("Test transfer transactions"); 380 // Init a transfer to client 381 let transfer_id = harness 382 .custom_transfer(102, FullHuPayto::new(harness.client.iban.clone(), "Client")) 383 .await; 384 // Should send 385 harness.worker().await?; 386 // Check transfer is still pending 387 harness 388 .expect_transfer_status(transfer_id, TransferState::pending, None) 389 .await; 390 // Wait for transaction to finalize 391 balance.expect(-102).await; 392 // Should register 393 harness.worker().await?; 394 // Check transfer is now successful 395 harness 396 .expect_transfer_status(transfer_id, TransferState::success, None) 397 .await; 398 399 step("Test transfer to self"); 400 // Init a transfer to self 401 let transfer_id = harness 402 .custom_transfer(101, FullHuPayto::new(harness.exchange.iban.clone(), "Self")) 403 .await; 404 // Should failed 405 harness.worker().await?; 406 // Check transfer failed 407 harness 408 .expect_transfer_status( 409 transfer_id, 410 TransferState::permanent_failure, 411 Some("409 FORRAS_SZAMLA_ESZAMLA_EGYEZIK 'A forrás és az ellenszámla egyezik!'"), 412 ) 413 .await; 414 415 step("Test transfer to unknown account"); 416 let transfer_id = harness.custom_transfer(103, unknown_account.clone()).await; 417 harness.worker().await?; 418 harness 419 .expect_transfer_status(transfer_id, TransferState::pending, None) 420 .await; 421 balance.expect(0).await; 422 harness.worker().await?; 423 harness 424 .expect_transfer_status(transfer_id, TransferState::permanent_failure, None) 425 .await; 426 427 step("Test unexpected outgoing"); 428 // Manual tx from the exchange 429 harness 430 .exchange_send(&format!("What is this ? {now}"), 4) 431 .await; 432 harness.worker().await?; 433 // Wait for transaction to finalize 434 balance.expect(-4).await; 435 harness.worker().await?; 436 437 step("Test transfer failure init-tx"); 438 harness.transfer(10).await; 439 set_failure_scenario(&["init-tx"]); 440 // TODO revert to assert_matches! once rust-lang#82775 is stable 441 assert!(matches!( 442 harness.worker().await, 443 Err(WorkerError::Injected(InjectedErr("init-tx"))) 444 )); 445 harness.worker().await?; 446 balance.expect(-10).await; 447 harness.worker().await?; 448 449 step("Test transfer failure submit-tx"); 450 harness.transfer(11).await; 451 set_failure_scenario(&["submit-tx"]); 452 // TODO revert to assert_matches! once rust-lang#82775 is stable 453 assert!(matches!( 454 harness.worker().await, 455 Err(WorkerError::Injected(InjectedErr("submit-tx"))) 456 )); 457 harness.worker().await?; 458 balance.expect(-11).await; 459 harness.worker().await?; 460 461 step("Test transfer all failures"); 462 harness.transfer(13).await; 463 set_failure_scenario(&["init-tx", "submit-tx"]); 464 // TODO revert to assert_matches! once rust-lang#82775 is stable 465 assert!(matches!( 466 harness.worker().await, 467 Err(WorkerError::Injected(InjectedErr("init-tx"))) 468 )); 469 // TODO revert to assert_matches! once rust-lang#82775 is stable 470 assert!(matches!( 471 harness.worker().await, 472 Err(WorkerError::Injected(InjectedErr("submit-tx"))) 473 )); 474 harness.worker().await?; 475 balance.expect(-13).await; 476 harness.worker().await?; 477 478 step("Test recover successful bounces"); 479 let code = harness 480 .client_send(&format!("will be bounced {now}"), 2) 481 .await; 482 balance.expect(2).await; 483 harness 484 .exchange_send(&format!("bounced: {}", code + 1), 2) 485 .await; 486 balance.expect(-2).await; 487 harness.worker().await?; 488 489 step("Test recover failed bounces"); 490 // Send malformed transaction 491 harness 492 .client_send(&format!("will be failed bounced {now}"), 3) 493 .await; 494 // Wait for it to be received because rejected transaction take too much time to appear in the transactions log 495 balance.expect(3).await; 496 // Bounce it manually 497 let received = harness.latest_tx(&harness.exchange).await; 498 let bounce_code = harness 499 .exchange_send_to( 500 &format!("bounce manually: {}", received.code), 501 3, 502 &unknown_account, 503 ) 504 .await; 505 harness.expect_status(bounce_code, TxStatus::Rejected).await; 506 // Should not bounce and catch the failure 507 harness.worker().await?; 508 // Wait for it to be bounce regardless because rejected transaction take too much time to appear in the transactions log 509 // TODO fix this 510 balance.expect(-3).await; 511 512 step("Finish"); 513 tokio::time::sleep(Duration::from_secs(5)).await; 514 harness.worker().await?; 515 balance.expect(0).await; 516 Ok(()) 517 } 518 519 /// Run online tests against real Magnet Bank backend 520 async fn online_harness(config: &Config, reset: bool) -> anyhow::Result<()> { 521 step("Run Magnet Bank online harness tests"); 522 523 step("Prepare db"); 524 let db_cfg = parse_db_cfg(config)?; 525 let pool = pool(db_cfg.cfg, "magnet_bank").await?; 526 let mut db = pool.acquire().await?.detach(); 527 dbinit(&mut db, db_cfg.sql_dir.as_ref(), "magnet-bank", reset).await?; 528 529 let cfg = HarnessCfg::parse(config)?; 530 let keys = setup::load(&cfg.worker)?; 531 let client = http_client::client(); 532 533 let harness = Harness::new(&cfg, &client, &pool, &keys).await; 534 535 step("Warmup worker"); 536 let _worker_task = { 537 let client = client.clone(); 538 let pool = pool.clone(); 539 let config = config.clone(); 540 tokio::spawn(async move { run_worker(&config, &pool, &client, false).await }) 541 }; 542 tokio::time::sleep(Duration::from_secs(25)).await; 543 544 let now = Timestamp::now(); 545 let balance = &mut Balances::new(&harness).await; 546 547 step("Test incoming transactions"); 548 let reserve_pub = EddsaPublicKey::rand(); 549 harness 550 .client_send(&format!("Taler {reserve_pub}"), 3) 551 .await; 552 harness 553 .client_send(&format!("Malformed test {now}"), 4) 554 .await; 555 balance.expect(3).await; 556 harness.expect_incoming(reserve_pub).await; 557 558 step("Test outgoing transactions"); 559 let transfer_self = harness 560 .custom_transfer(1, FullHuPayto::new(harness.exchange.iban.clone(), "Self")) 561 .await; 562 let transfer_id = harness 563 .custom_transfer(2, FullHuPayto::new(harness.client.iban.clone(), "Client")) 564 .await; 565 balance.expect(-2).await; 566 harness 567 .expect_transfer_status( 568 transfer_self, 569 TransferState::permanent_failure, 570 Some("409 FORRAS_SZAMLA_ESZAMLA_EGYEZIK 'A forrás és az ellenszámla egyezik!'"), 571 ) 572 .await; 573 harness 574 .expect_transfer_status(transfer_id, TransferState::success, None) 575 .await; 576 577 step("Finish"); 578 tokio::time::sleep(Duration::from_secs(5)).await; 579 balance.expect(0).await; 580 581 Ok(()) 582 } 583 584 fn main() { 585 let args = Args::parse(); 586 taler_main(CONFIG_SOURCE, args.common, async |cfg| match args.cmd { 587 Command::Logic { reset } => logic_harness(cfg, reset).await, 588 Command::Online { reset } => online_harness(cfg, reset).await, 589 }); 590 }