api.rs (36008B)
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::{path::PathBuf, sync::Arc}; 21 22 use axum::{ 23 Json, Router, 24 extract::State, 25 response::{IntoResponse, Redirect, Response}, 26 routing::get, 27 }; 28 use prometheus_client::{ 29 encoding::EncodeLabelSet, 30 metrics::{counter::Counter, family::Family, gauge::Gauge}, 31 registry::Registry, 32 }; 33 use serde::{Deserialize, Serialize}; 34 use sqlx::PgPool; 35 use taler_api::{error::ApiResult, extract::Query, notification::NotificationChannel}; 36 use taler_common::{ 37 api::LibtoolVersion, 38 types::amount::{Amount, Currency}, 39 }; 40 use taler_macros::{EnumMeta, api_config}; 41 use tower_http::services::ServeDir; 42 use tracing::warn; 43 use uuid::Uuid; 44 45 use crate::{ 46 TanChannel, 47 api::{ 48 account::account_api, 49 cashout::cashout_api, 50 conversion::conversion_api, 51 observability::observability_api, 52 prepared::prepared_api, 53 revenue::revenue_api, 54 tan::tan_api, 55 token::token_api, 56 tx::tx_api, 57 wire::wire_api, 58 withdrawal::{WithdrawalStatus, withdrawal_api}, 59 }, 60 auth::AdminRAuth, 61 config::{BankCfg, CurrencySpecification}, 62 db::{monitor, notification_listener}, 63 }; 64 65 pub mod account; 66 pub mod cashout; 67 pub mod conversion; 68 pub mod observability; 69 pub mod prepared; 70 pub mod revenue; 71 pub mod tan; 72 pub mod token; 73 pub mod tx; 74 pub mod wire; 75 pub mod withdrawal; 76 77 const IMPLEMENTATION: &str = "urn:net:taler:specs:libeufin-bank:taler-rust"; 78 const COREBANK_API_VERSION: LibtoolVersion = LibtoolVersion::new(12, 1, 0); 79 const CONVERSION_API_VERSION: LibtoolVersion = LibtoolVersion::new(2, 1, 1); 80 const INTEGRATION_API_VERSION: LibtoolVersion = LibtoolVersion::new(5, 1, 5); 81 const OBSERVABILITY_API_VERSION: LibtoolVersion = LibtoolVersion::new(0, 0, 0); 82 83 pub struct BankState { 84 pub db: PgPool, 85 pub cfg: BankCfg, 86 pub tx_channel: NotificationChannel<u64, i64>, 87 pub taler_out_channel: NotificationChannel<u64, i64>, 88 pub taler_in_channel: NotificationChannel<u64, i64>, 89 pub revenue_channel: NotificationChannel<u64, i64>, 90 pub withdrawal_channel: NotificationChannel<Uuid, Option<WithdrawalStatus>>, 91 pub metrics: Metrics, 92 registry: Registry, 93 } 94 95 #[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] 96 pub struct TanChannelLabels { 97 channel: &'static str, 98 exit: i32, 99 } 100 101 #[derive(Default)] 102 pub struct Metrics { 103 db_access: Gauge, 104 tan_channel: Family<TanChannelLabels, Counter>, 105 } 106 107 impl Metrics { 108 pub fn registry(&self) -> Registry { 109 let mut registry = Registry::default(); 110 111 registry.register( 112 "db_access", 113 "Whether the last database metrics refresh succeeded", 114 self.db_access.clone(), 115 ); 116 117 registry 118 } 119 120 pub async fn sync(&self, db: &PgPool) { 121 let test = sqlx::query("SELECT 1").fetch_one(db).await.is_ok(); 122 self.db_access.set(if test { 1 } else { 0 }); 123 } 124 125 pub fn register_tan_result(&self, channel: TanChannel, exit: i32) { 126 self.tan_channel 127 .get_or_create(&TanChannelLabels { 128 channel: match channel { 129 TanChannel::sms => "sms", 130 TanChannel::email => "email", 131 }, 132 exit, 133 }) 134 .inc(); 135 } 136 } 137 138 impl BankState { 139 pub async fn start(pool: PgPool, cfg: BankCfg) -> Self { 140 let tx_channel = NotificationChannel::new(); 141 let taler_in_channel = NotificationChannel::new(); 142 let taler_out_channel = NotificationChannel::new(); 143 let revenue_channel = NotificationChannel::new(); 144 let withdrawal_channel = NotificationChannel::new(); 145 146 tokio::spawn(notification_listener( 147 pool.clone(), 148 tx_channel.clone(), 149 taler_out_channel.clone(), 150 taler_in_channel.clone(), 151 revenue_channel.clone(), 152 withdrawal_channel.clone(), 153 )); 154 155 let metrics = Metrics::default(); 156 157 Self { 158 cfg, 159 db: pool, 160 tx_channel, 161 taler_in_channel, 162 taler_out_channel, 163 revenue_channel, 164 withdrawal_channel, 165 registry: metrics.registry(), 166 metrics, 167 } 168 } 169 } 170 171 #[api_config("taler-corebank")] 172 #[derive(Debug, Clone, Serialize)] 173 pub struct Config<'a> { 174 pub currency: Currency, 175 pub currency_specification: &'a CurrencySpecification, 176 pub base_url: &'a str, 177 pub bank_name: &'a str, 178 pub allow_conversion: bool, 179 pub allow_registrations: bool, 180 pub allow_deletions: bool, 181 pub allow_edit_name: bool, 182 pub allow_edit_cashout_payto_uri: bool, 183 pub default_debit_threshold: Amount, 184 pub supported_tan_channels: Vec<TanChannel>, 185 pub wire_type: &'a str, 186 pub wire_transfer_fees: Amount, 187 pub min_wire_transfer_amount: Amount, 188 pub max_wire_transfer_amount: Amount, 189 } 190 191 #[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, sqlx::Type, EnumMeta)] 192 #[sqlx(type_name = "stat_timeframe_enum")] 193 #[enum_meta(Str)] 194 #[allow(non_camel_case_types)] 195 pub enum Timeframe { 196 #[default] 197 hour, 198 day, 199 month, 200 year, 201 } 202 203 #[derive(Debug, Clone, Serialize, Deserialize)] 204 pub struct MonitorParams { 205 #[serde(default)] 206 pub timeframe: Timeframe, 207 pub date_s: Option<u64>, 208 } 209 210 #[derive(Debug, Clone, Serialize, Deserialize)] 211 #[serde(tag = "type")] 212 pub enum MonitorResponse { 213 #[serde(rename = "no-conversions", rename_all = "camelCase")] 214 Simple { 215 taler_in_count: u64, 216 taler_in_volume: Amount, 217 taler_out_count: u64, 218 taler_out_volume: Amount, 219 }, 220 #[serde(rename = "with-conversions", rename_all = "camelCase")] 221 Conversion { 222 cashin_count: u64, 223 cashin_regional_volume: Amount, 224 cashin_fiat_volume: Amount, 225 cashout_count: u64, 226 cashout_regional_volume: Amount, 227 cashout_fiat_volume: Amount, 228 taler_in_count: u64, 229 taler_in_volume: Amount, 230 taler_out_count: u64, 231 taler_out_volume: Amount, 232 }, 233 } 234 235 async fn config(State(state): State<Arc<BankState>>) -> Response { 236 Json(Config { 237 name: (), 238 version: COREBANK_API_VERSION, 239 implementation: Some(IMPLEMENTATION), 240 currency: state.cfg.regional_currency, 241 currency_specification: &state.cfg.regional_currency_spec, 242 base_url: state.cfg.base_url.as_str(), 243 bank_name: &state.cfg.name, 244 allow_conversion: state.cfg.fiat.is_some(), 245 allow_registrations: state.cfg.allow_registration, 246 allow_deletions: state.cfg.allow_account_deletion, 247 allow_edit_name: state.cfg.allow_edit_name, 248 allow_edit_cashout_payto_uri: state.cfg.allow_edit_cashout, 249 default_debit_threshold: state.cfg.default_debt_limit, 250 supported_tan_channels: state.cfg.tan_channels.keys().copied().collect::<Vec<_>>(), 251 wire_type: state.cfg.wire_method.as_ref(), 252 wire_transfer_fees: state.cfg.wire_transfer_fees, 253 min_wire_transfer_amount: state.cfg.min_amount, 254 max_wire_transfer_amount: state.cfg.max_amount, 255 }) 256 .into_response() 257 } 258 259 pub fn bank_api(state: Arc<BankState>) -> Router { 260 let router = Router::new().route("/config", get(config)); 261 if let Some(path) = &state.cfg.spa_path { 262 if !PathBuf::from(path).is_dir() { 263 warn!(target: "api", "SPA not found at '{path}'"); 264 if !state.cfg.fallback_spa.is_dir() { 265 warn!(target: "api", "SPA fallback not found at '{}'", state.cfg.fallback_spa.to_string_lossy()) 266 } 267 } 268 router.nest_service( 269 "/webui/", 270 ServeDir::new(path).fallback(ServeDir::new(&state.cfg.fallback_spa)), 271 ) 272 273 } else { 274 if !state.cfg.fallback_spa.is_dir() { 275 warn!(target: "api", "SPA fallback not found at '{}'", state.cfg.fallback_spa.to_string_lossy()) 276 } 277 router.nest_service("/webui/", ServeDir::new(&state.cfg.fallback_spa)) 278 } 279 .route("/", get(async || Redirect::permanent("/webui/"))) 280 .route( 281 "/monitor", 282 get( 283 async |Query(params): Query<MonitorParams>, 284 _: AdminRAuth, 285 State(state): State<Arc<BankState>>| { 286 ApiResult::Ok(Json( 287 monitor( 288 &state.db, 289 &state.cfg.regional_currency, 290 state.cfg.fiat_currency(), 291 ¶ms, 292 ) 293 .await?, 294 )) 295 }, 296 ), 297 ) 298 .merge(token_api()) 299 .merge(account_api()) 300 .merge(tx_api()) 301 .merge(withdrawal_api()) 302 .merge(cashout_api(state.clone())) 303 .merge(tan_api()) 304 .merge(conversion_api(state.clone())) 305 .merge(wire_api()) 306 .merge(prepared_api()) 307 .merge(revenue_api()) 308 .merge(observability_api()) 309 .with_state(state) 310 } 311 312 #[cfg(any(test, feature = "test-utils"))] 313 pub mod test { 314 use std::{ 315 collections::BTreeMap, 316 fmt::Display, 317 ops::{Deref, DerefMut}, 318 sync::Arc, 319 }; 320 321 use axum::{ 322 Router, 323 extract::{Request, State}, 324 http::{HeaderValue, Method, StatusCode, header::AUTHORIZATION}, 325 middleware::{self, Next}, 326 }; 327 use compact_str::{CompactString, CompactStringExt}; 328 use jiff::Timestamp; 329 use rand::{random_range, seq::IndexedRandom}; 330 use sqlx::{PgPool, postgres::PgConnectOptions}; 331 use taler_api::{api::TalerRouter, db::BindHelper}; 332 use taler_common::{ 333 api::{EddsaPublicKey, HashCode, ShortHashCode}, 334 config::Config, 335 db::{dbinit, pool}, 336 error_code::ErrorCode, 337 types::{ 338 amount::{Amount, Decimal, decimal}, 339 payto::PaytoURI, 340 }, 341 }; 342 use taler_macros::db_test; 343 use taler_test_utils::{ 344 json, 345 server::{TestRequest, TestResponse, TestServer as _}, 346 }; 347 use uuid::Uuid; 348 349 use crate::{ 350 TanChannel, 351 api::{ 352 BankState, 353 account::{ 354 AccountData, Balance, CreditDebitInfo, create_admin_account, rand_iban_payto, 355 }, 356 bank_api, 357 conversion::{ConversionRateClassResponse, ConversionResponse}, 358 tan::ChallengeResponse, 359 withdrawal::BankAccountCreateWithdrawalResponse, 360 }, 361 config::BankCfg, 362 constants::CONFIG_SOURCE, 363 db::{self, account::CreationResult}, 364 mfa::TALER_CHALLENGE_IDS, 365 payto::BankPayto, 366 }; 367 368 pub enum Auth { 369 Admin, 370 Optional, 371 UserOrAdmin, 372 UserOnly, 373 Token, 374 } 375 376 fn pw_auth(req: TestRequest, username: Option<&str>) -> TestRequest { 377 let username: CompactString = username 378 .unwrap_or_else(|| extract_username(req.url.path())) 379 .into(); 380 req.basic_auth(&username, &format!("{username}-password")) 381 } 382 383 fn extract_username(path: &str) -> &str { 384 if path.contains("admin") { 385 "admin" 386 } else { 387 path.split('/').nth(2).unwrap() 388 } 389 } 390 391 pub struct BankClient { 392 server: Router, 393 tokens: BTreeMap<CompactString, String>, 394 pub state: Arc<BankState>, 395 } 396 397 impl BankClient { 398 pub async fn new(cfg: &Config, db: PgPool) -> Self { 399 let state = Arc::new(BankState::start(db.clone(), BankCfg::parse(cfg).unwrap()).await); 400 Self { 401 server: bank_api(state.clone()), 402 tokens: Default::default(), 403 state, 404 } 405 } 406 407 pub async fn cache_tokens(&mut self, usernames: &[&str]) { 408 let tasks = usernames 409 .iter() 410 .map(|username| { 411 let username = CompactString::from(*username); 412 async { 413 let res = pw_auth( 414 self.server.post(format!("/accounts/{username}/token")), 415 Some(&username), 416 ) 417 .json(json!({ 418 "scope": "readwrite", 419 "duration": { 420 "d_us": "forever" 421 } 422 })) 423 .await 424 .maybe_challenge(self) 425 .await 426 .assert_ok_json::<serde_json::Value>(); 427 let token = res["access_token"].as_str().unwrap(); 428 (username, format!("Bearer {token}")) 429 } 430 }) 431 .collect::<Vec<_>>(); 432 for (username, token) in futures::future::join_all(tasks).await { 433 self.tokens.insert(username, token); 434 } 435 } 436 437 pub async fn admin_router(&self) -> Router { 438 self.server.clone().layer(middleware::from_fn_with_state( 439 Arc::new(self.tokens.clone()), 440 async |State(tokens): State<Arc<BTreeMap<CompactString, String>>>, 441 mut req: Request, 442 next: Next| { 443 let path = req.uri().path(); 444 if path.starts_with("/accounts") && !path.contains("admin") { 445 let username = extract_username(req.uri().path()); 446 let header = HeaderValue::from_str(&tokens[username]).unwrap(); 447 req.headers_mut().insert(AUTHORIZATION, header); 448 } else { 449 req.headers_mut().insert( 450 AUTHORIZATION, 451 HeaderValue::from_str(&tokens["admin"]).unwrap(), 452 ); 453 } 454 next.run(req).await 455 }, 456 )) 457 } 458 459 fn requesta( 460 &self, 461 method: Method, 462 path: impl AsRef<str>, 463 username: Option<&str>, 464 ) -> TestRequest { 465 let path = path.as_ref(); 466 let username = username.unwrap_or_else(|| extract_username(path)); 467 let token = &self.tokens[username]; 468 self.server 469 .request(method, path) 470 .header(AUTHORIZATION, token) 471 } 472 473 pub fn postpw(&self, path: impl AsRef<str>) -> TestRequest { 474 pw_auth(self.server.request(Method::POST, path), None) 475 } 476 477 pub fn geta(&self, path: impl AsRef<str>) -> TestRequest { 478 self.requesta(Method::GET, path, None) 479 } 480 481 pub fn posta(&self, path: impl AsRef<str>) -> TestRequest { 482 self.requesta(Method::POST, path, None) 483 } 484 485 pub fn patcha(&self, path: impl AsRef<str>) -> TestRequest { 486 self.requesta(Method::PATCH, path, None) 487 } 488 489 pub fn deletea(&self, path: impl AsRef<str>) -> TestRequest { 490 self.requesta(Method::DELETE, path, None) 491 } 492 493 pub fn get_admin(&self, path: impl AsRef<str>) -> TestRequest { 494 self.requesta(Method::GET, path, Some("admin")) 495 } 496 497 pub fn post_admin(&self, path: impl AsRef<str>) -> TestRequest { 498 self.requesta(Method::POST, path, Some("admin")) 499 } 500 501 pub fn patch_admin(&self, path: impl AsRef<str>) -> TestRequest { 502 self.requesta(Method::PATCH, path, Some("admin")) 503 } 504 505 pub fn delete_admin(&self, path: impl AsRef<str>) -> TestRequest { 506 self.requesta(Method::DELETE, path, Some("admin")) 507 } 508 509 pub async fn auth_routine(&self, method: Method, path: impl AsRef<str>, auth: Auth) { 510 let path = path.as_ref(); 511 // Bad header 512 self.request(method.clone(), path) 513 .header(AUTHORIZATION, "WTF") 514 .await 515 .assert_error(ErrorCode::GENERIC_UNAUTHORIZED); 516 517 if !matches!(auth, Auth::Token) { 518 if !matches!(auth, Auth::Optional) { 519 // No header 520 self.request(method.clone(), path) 521 .await 522 .assert_error_status( 523 ErrorCode::GENERIC_PARAMETER_MISSING, 524 StatusCode::UNAUTHORIZED, 525 ); 526 } 527 528 // Other account 529 self.requesta(method.clone(), path, Some("customer")) 530 .await 531 .assert_error(ErrorCode::GENERIC_FORBIDDEN); 532 } 533 534 match auth { 535 Auth::Admin => { 536 self.requesta(method.clone(), path, Some("merchant")) 537 .await 538 .assert_error(ErrorCode::GENERIC_FORBIDDEN); 539 } 540 Auth::UserOrAdmin | Auth::Optional => {} 541 Auth::Token => { 542 self.requesta(method.clone(), path, Some("admin")) 543 .await 544 .assert_error(ErrorCode::GENERIC_TOKEN_PERMISSION_INSUFFICIENT); 545 } 546 Auth::UserOnly => { 547 self.requesta(method.clone(), path, Some("admin")) 548 .await 549 .assert_error(ErrorCode::GENERIC_FORBIDDEN); 550 } 551 } 552 } 553 554 pub async fn fill_tan_info(&self, username: &str) { 555 self.patch_admin(format!("/accounts/{username}")) 556 .json(json!({ 557 "contact_data": { 558 "phone": format!("+{}", random_range(2000..10000)) 559 }, 560 "tan_channel": "sms" 561 })) 562 .await 563 .assert_no_content(); 564 } 565 566 pub async fn cashout(&self, amount: impl Display) { 567 let amount = format!("{}:{amount}", self.state.cfg.regional_currency); 568 self.posta("/accounts/customer/cashouts") 569 .json({ 570 json!({ 571 "request_uid": ShortHashCode::rand(), 572 "amount_debit": amount, 573 "amount_credit": self.convert(&amount).await 574 }) 575 }) 576 .await 577 .assert_ok() 578 } 579 580 pub async fn cashin(&self, amount: &str) { 581 sqlx::query("SELECT 0 FROM cashin($1, $2, $3, $4)") 582 .bind_timestamp(&Timestamp::now()) 583 .bind(ShortHashCode::rand()) 584 .bind(decimal(amount)) 585 .bind("") 586 .fetch_one(&self.state.db) 587 .await 588 .unwrap(); 589 } 590 591 pub async fn create_conversion_rate_class(&self) -> u64 { 592 self.post_admin("/conversion-rate-classes") 593 .json(json!({ 594 "name": format!("Gen class {}", Timestamp::now()) 595 })) 596 .await 597 .assert_ok_json::<ConversionRateClassResponse>() 598 .conversion_rate_class_id 599 } 600 601 pub async fn convert(&self, amount: impl Display) -> Amount { 602 self.get(format!( 603 "/conversion-info/cashout-rate?amount_debit={amount}" 604 )) 605 .await 606 .assert_ok_json::<ConversionResponse>() 607 .amount_credit 608 } 609 610 /** Set [account] debit threshold to [maxDebt] amount */ 611 pub async fn set_max_debt(&self, username: &str, amount: &str) { 612 self.patch_admin(format!("/accounts/{username}")) 613 .json(json!({ 614 "debit_threshold": format!("{}:{amount}", self.state.cfg.regional_currency) 615 })) 616 .await 617 .assert_no_content(); 618 } 619 620 /** Check [account] balance is [amount], [amount] is prefixed with + for credit and - for debit */ 621 pub async fn assert_balance(&self, username: &str, expected: &str) { 622 let res: AccountData = self 623 .get_admin(format!("/accounts/{username}")) 624 .await 625 .assert_ok_json(); 626 let Balance { 627 amount, 628 credit_debit_indicator, 629 } = res.balance; 630 let prefix = match credit_debit_indicator { 631 CreditDebitInfo::credit => "", 632 CreditDebitInfo::debit => "-", 633 }; 634 pretty_assertions::assert_eq!(format!("{prefix}{}", amount.decimal()), expected); 635 } 636 } 637 638 impl Deref for BankClient { 639 type Target = Router; 640 641 fn deref(&self) -> &Self::Target { 642 &self.server 643 } 644 } 645 646 pub struct BankTestCtx { 647 pub merchant_payto: BankPayto, 648 pub exchange_payto: BankPayto, 649 pub customer_payto: BankPayto, 650 pub unknown_payto: BankPayto, 651 pub tmp_payto: BankPayto, 652 pub admin_payto: BankPayto, 653 pub client: BankClient, 654 } 655 656 impl BankTestCtx { 657 pub async fn new(options: PgConnectOptions, conf: &str) -> Self { 658 let cfg = Config::load(CONFIG_SOURCE, Some(format!("conf/{conf}"))).unwrap(); 659 let cfg = BankCfg::parse(&cfg).unwrap(); 660 let dir = cfg.db_cfg.sql_dir.as_ref(); 661 let db = pool(options, "libeufin_bank").await.unwrap(); 662 let mut conn = db.acquire().await.unwrap(); 663 dbinit(&mut conn, dir, "libeufin-bank", true).await.unwrap(); 664 dbinit(&mut conn, dir, "libeufin-nexus", true) 665 .await 666 .unwrap(); 667 let procedure = 668 std::fs::read_to_string(dir.join("libeufin-conversion-setup.sql")).unwrap(); 669 sqlx::raw_sql(&procedure).execute(&mut *conn).await.unwrap(); 670 drop(conn); 671 672 let state = Arc::new(BankState::start(db.clone(), cfg).await); 673 let server = bank_api(state.clone()).finalize(); 674 675 let merchant_payto = db::account::create( 676 &db, 677 &state.cfg.ctx, 678 &state.cfg.pw_crypto, 679 "merchant", 680 "merchant-password", 681 "Merchant", 682 None, 683 None, 684 None, 685 &rand_iban_payto().into_inner().into(), 686 false, 687 false, 688 Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), 689 Amount::zero(&state.cfg.regional_currency), 690 &[], 691 false, 692 None, 693 ) 694 .await 695 .unwrap() 696 .assert_success(); 697 let exchange_payto = db::account::create( 698 &db, 699 &state.cfg.ctx, 700 &state.cfg.pw_crypto, 701 "exchange", 702 "exchange-password", 703 "Exchange", 704 None, 705 None, 706 None, 707 &rand_iban_payto().into_inner().into(), 708 false, 709 true, 710 Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), 711 Amount::zero(&state.cfg.regional_currency), 712 &[], 713 false, 714 None, 715 ) 716 .await 717 .unwrap() 718 .assert_success(); 719 let customer_payto = db::account::create( 720 &db, 721 &state.cfg.ctx, 722 &state.cfg.pw_crypto, 723 "customer", 724 "customer-password", 725 "Customer", 726 None, 727 None, 728 None, 729 &rand_iban_payto().into_inner().into(), 730 false, 731 false, 732 Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), 733 Amount::zero(&state.cfg.regional_currency), 734 &[], 735 false, 736 None, 737 ) 738 .await 739 .unwrap() 740 .assert_success(); 741 742 let res = create_admin_account(&db, &state.cfg, Some("admin-password")) 743 .await 744 .unwrap(); 745 746 let admin_payto = match res { 747 CreationResult::Success(payto) => payto, 748 _ => unreachable!(), 749 }; 750 751 let mut ctx = BankTestCtx { 752 merchant_payto: merchant_payto.into(), 753 exchange_payto: exchange_payto.into(), 754 customer_payto: customer_payto.into(), 755 unknown_payto: rand_iban_payto().convert(), 756 tmp_payto: rand_iban_payto().convert(), 757 admin_payto: admin_payto.into(), 758 client: BankClient { 759 server, 760 tokens: BTreeMap::new(), 761 state, 762 }, 763 }; 764 ctx.client 765 .cache_tokens(&["admin", "merchant", "exchange", "customer"]) 766 .await; 767 768 if ctx.state.cfg.fiat.is_some() { 769 // Set conversion rates 770 ctx.post_admin("/conversion-info/conversion-rate") 771 .json(json!({ 772 "cashin_ratio" :"0.8", 773 "cashin_fee" :"KUDOS:0.02", 774 "cashin_tiny_amount" :"KUDOS:0.01", 775 "cashin_rounding_mode" :"nearest", 776 "cashin_min_amount" :"EUR:0", 777 "cashout_ratio" :"1.26", 778 "cashout_fee" :"EUR:0.003", 779 "cashout_tiny_amount" :"EUR:0.01", 780 "cashout_rounding_mode" :"zero", 781 "cashout_min_amount" :"KUDOS:0.1" 782 })) 783 .await 784 .assert_no_content(); 785 ctx.create_conversion_rate_class().await; 786 } 787 788 ctx 789 } 790 791 pub async fn swap_cfg(mut self, conf: &str) -> Self { 792 let cfg = Config::load(CONFIG_SOURCE, Some(format!("conf/{conf}"))).unwrap(); 793 794 let state = Arc::new( 795 BankState::start(self.state.db.clone(), BankCfg::parse(&cfg).unwrap()).await, 796 ); 797 self.client.server = bank_api(state.clone()); 798 self.state = state; 799 self 800 } 801 802 pub async fn fill_cashout_info(&self, username: &str) { 803 self.patch_admin(format!("/accounts/{username}")) 804 .json(json!({ 805 "cashout_payto_uri": self.unknown_payto, 806 })) 807 .await 808 .assert_no_content(); 809 } 810 811 pub async fn tmp_payto(&mut self) -> PaytoURI { 812 self.tmp_payto = rand_iban_payto().convert(); 813 self.tmp_payto.as_uri() 814 } 815 816 pub async fn tx_s(&self, from: &str, amount: &str, to: &str, subject: impl Display) { 817 let payto = match to { 818 "admin" => &self.admin_payto, 819 "merchant" => &self.merchant_payto, 820 "customer" => &self.customer_payto, 821 "exchange" => &self.exchange_payto, 822 _ => &self.tmp_payto, 823 }; 824 self.posta(format!("/accounts/{from}/transactions")) 825 .json(json!({ 826 "payto_uri": format!("{payto}?message={subject}"), 827 "amount": format!("{}:{amount}", self.state.cfg.regional_currency), 828 })) 829 .await 830 .maybe_challenge(self) 831 .await 832 .assert_ok(); 833 } 834 835 pub async fn tx(&self, from: &str, amount: &str, to: &str) { 836 self.tx_s(from, amount, to, "payout").await 837 } 838 839 pub async fn transfer( 840 &self, 841 amount: &str, 842 payto: &BankPayto, 843 metadata: Option<CompactString>, 844 ) { 845 self.posta("/accounts/exchange/taler-wire-gateway/transfer") 846 .json({ 847 json!({ 848 "request_uid": HashCode::rand(), 849 "amount": format!("{}:{amount}", self.state.cfg.regional_currency), 850 "exchange_base_url": "http://exchange.example.com/", 851 "wtid": ShortHashCode::rand(), 852 "credit_account": payto, 853 "metadata": metadata, 854 }) 855 }) 856 .await 857 .assert_ok() 858 } 859 860 /** Perform a taler incoming transaction of [amount] from merchant to exchange */ 861 pub async fn add_incoming(&self, amount: &str) { 862 self.post_admin("/accounts/exchange/taler-wire-gateway/admin/add-incoming") 863 .json({ 864 json!({ 865 "amount": format!("{}:{amount}", self.state.cfg.regional_currency), 866 "reserve_pub": EddsaPublicKey::rand(), 867 "debit_account": self.merchant_payto 868 }) 869 }) 870 .await 871 .assert_ok() 872 } 873 874 pub async fn add_kyc(&self, amount: &str) { 875 self.post_admin("/accounts/exchange/taler-wire-gateway/admin/add-kycauth") 876 .json({ 877 json!({ 878 "amount": format!("{}:{amount}", self.state.cfg.regional_currency), 879 "account_pub": EddsaPublicKey::rand(), 880 "debit_account": self.merchant_payto 881 }) 882 }) 883 .await 884 .assert_ok() 885 } 886 887 pub async fn withdrawal(&self, amount: &str) { 888 let uuid = self 889 .posta("/accounts/merchant/withdrawals") 890 .json({ 891 json!({ 892 "amount": format!("{}:{amount}", self.state.cfg.regional_currency) 893 }) 894 }) 895 .await 896 .assert_ok_json::<BankAccountCreateWithdrawalResponse>() 897 .withdrawal_id; 898 self.withdraw_select(uuid).await; 899 self.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) 900 .json(json!({})) 901 .await 902 .assert_no_content(); 903 } 904 905 pub async fn withdraw_select(&self, uuid: Uuid) -> EddsaPublicKey { 906 let key = EddsaPublicKey::rand(); 907 self.post(format!("/taler-integration/withdrawal-operation/{uuid}")) 908 .json(json!({ 909 "reserve_pub": key, 910 "selected_exchange": self.exchange_payto 911 })) 912 .await 913 .assert_ok(); 914 key 915 } 916 } 917 918 impl Deref for BankTestCtx { 919 type Target = BankClient; 920 921 fn deref(&self) -> &Self::Target { 922 &self.client 923 } 924 } 925 926 impl DerefMut for BankTestCtx { 927 fn deref_mut(&mut self) -> &mut Self::Target { 928 &mut self.client 929 } 930 } 931 932 pub async fn bank_setup(db: PgConnectOptions) -> BankTestCtx { 933 bank_setup_conf(db, "test.conf").await 934 } 935 936 pub async fn bank_setup_conf(db: PgConnectOptions, conf: &str) -> BankTestCtx { 937 BankTestCtx::new(db, conf).await 938 } 939 940 pub fn tan_code(info: &str) -> Option<CompactString> { 941 let path = format!("/tmp/tan-{}.txt", info); 942 let code = match std::fs::read_to_string(&path) { 943 Ok(f) => f, 944 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None, 945 Err(e) => panic!("{:?}", e), 946 }; 947 std::fs::remove_file(path).unwrap(); 948 Some(code.split(' ').next().unwrap().into()) 949 } 950 951 pub trait MfaRequest { 952 fn assert_challenge_check( 953 &self, 954 ctx: &BankClient, 955 check: impl AsyncFnOnce(&ChallengeResponse), 956 ) -> impl std::future::Future<Output = Self>; 957 958 fn assert_challenge(&self, ctx: &BankClient) -> impl std::future::Future<Output = Self> { 959 self.assert_challenge_check(ctx, async |_| {}) 960 } 961 962 fn maybe_challenge(self, ctx: &BankClient) -> impl std::future::Future<Output = Self>; 963 } 964 965 impl MfaRequest for TestResponse { 966 async fn assert_challenge_check( 967 &self, 968 ctx: &BankClient, 969 check: impl AsyncFnOnce(&ChallengeResponse), 970 ) -> TestResponse { 971 let res: ChallengeResponse = self.assert_accepted_json(); 972 let username = self.uri.path().split('/').nth(2).unwrap(); 973 974 let challenges = if res.combi_and { 975 &res.challenges 976 } else { 977 std::slice::from_ref(res.challenges.choose(&mut rand::rng()).unwrap()) 978 }; 979 980 for challenge in challenges { 981 ctx.post(format!( 982 "/accounts/{username}/challenge/{}", 983 challenge.challenge_id 984 )) 985 .await 986 .assert_ok(); 987 } 988 check(&res).await; 989 990 for challenge in challenges { 991 let info = if challenge.tan_info == "REDACTED" { 992 match challenge.tan_channel { 993 TanChannel::sms => "+4567", 994 TanChannel::email => "test@gmail.com", 995 } 996 } else { 997 &challenge.tan_info 998 }; 999 let code = tan_code(info).unwrap(); 1000 ctx.post(format!( 1001 "/accounts/{username}/challenge/{}/confirm", 1002 challenge.challenge_id 1003 )) 1004 .json(json!({ "tan": code })) 1005 .await 1006 .assert_no_content(); 1007 } 1008 // Recover body from request 1009 let ids = res 1010 .challenges 1011 .into_iter() 1012 .map(|it| it.challenge_id) 1013 .join_compact(", "); 1014 let req = ctx 1015 .request(self.method.clone(), self.uri.path()) 1016 .header(TALER_CHALLENGE_IDS, ids.to_string()); 1017 if let Some(auth) = self.req_headers.get(AUTHORIZATION) { 1018 req.header(AUTHORIZATION, auth) 1019 } else { 1020 req 1021 } 1022 .raw_json(self.req_body.clone()) 1023 .await 1024 } 1025 1026 async fn maybe_challenge(self, ctx: &BankClient) -> Self { 1027 if self.status == StatusCode::ACCEPTED { 1028 self.assert_challenge(ctx).await 1029 } else { 1030 self 1031 } 1032 } 1033 } 1034 1035 #[db_test(raw)] 1036 async fn corebank(db: PgConnectOptions) { 1037 use crate::api::MonitorResponse; 1038 let ctx = bank_setup(db).await; 1039 1040 ctx.auth_routine(Method::GET, "/monitor", Auth::Admin).await; 1041 1042 ctx.get("/config").await.assert_ok(); 1043 1044 ctx.get_admin("/monitor?timeframe=day&which=25") 1045 .await 1046 .assert_ok_json::<MonitorResponse>(); 1047 ctx.get_admin("/monitor?timeframe=day=which=25") 1048 .await 1049 .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); 1050 1051 ctx.fill_cashout_info("customer").await; 1052 ctx.cashout("1").await; 1053 ctx.get_admin("/monitor") 1054 .await 1055 .assert_ok_json::<MonitorResponse>(); 1056 } 1057 }