api.rs (36351B)
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::{AssertSqlSafe, PgPool, pool::PoolOptions, 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_with_options}, 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, Metrics, 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 for username in usernames { 409 let username = CompactString::from(*username); 410 let res = pw_auth( 411 self.server.post(format!("/accounts/{username}/token")), 412 Some(&username), 413 ) 414 .json(json!({ 415 "scope": "readwrite", 416 "duration": { 417 "d_us": "forever" 418 } 419 })) 420 .await 421 .maybe_challenge(self) 422 .await 423 .assert_ok_json::<serde_json::Value>(); 424 let token = res["access_token"].as_str().unwrap(); 425 self.tokens.insert(username, format!("Bearer {token}")); 426 } 427 } 428 429 pub async fn admin_router(&self) -> Router { 430 self.server.clone().layer(middleware::from_fn_with_state( 431 Arc::new(self.tokens.clone()), 432 async |State(tokens): State<Arc<BTreeMap<CompactString, String>>>, 433 mut req: Request, 434 next: Next| { 435 let path = req.uri().path(); 436 if path.starts_with("/accounts") && !path.contains("admin") { 437 let username = extract_username(req.uri().path()); 438 let header = HeaderValue::from_str(&tokens[username]).unwrap(); 439 req.headers_mut().insert(AUTHORIZATION, header); 440 } else { 441 req.headers_mut().insert( 442 AUTHORIZATION, 443 HeaderValue::from_str(&tokens["admin"]).unwrap(), 444 ); 445 } 446 next.run(req).await 447 }, 448 )) 449 } 450 451 fn requesta( 452 &self, 453 method: Method, 454 path: impl AsRef<str>, 455 username: Option<&str>, 456 ) -> TestRequest { 457 let path = path.as_ref(); 458 let username = username.unwrap_or_else(|| extract_username(path)); 459 let token = &self.tokens[username]; 460 self.server 461 .request(method, path) 462 .header(AUTHORIZATION, token) 463 } 464 465 pub fn postpw(&self, path: impl AsRef<str>) -> TestRequest { 466 pw_auth(self.server.request(Method::POST, path), None) 467 } 468 469 pub fn geta(&self, path: impl AsRef<str>) -> TestRequest { 470 self.requesta(Method::GET, path, None) 471 } 472 473 pub fn posta(&self, path: impl AsRef<str>) -> TestRequest { 474 self.requesta(Method::POST, path, None) 475 } 476 477 pub fn patcha(&self, path: impl AsRef<str>) -> TestRequest { 478 self.requesta(Method::PATCH, path, None) 479 } 480 481 pub fn deletea(&self, path: impl AsRef<str>) -> TestRequest { 482 self.requesta(Method::DELETE, path, None) 483 } 484 485 pub fn get_admin(&self, path: impl AsRef<str>) -> TestRequest { 486 self.requesta(Method::GET, path, Some("admin")) 487 } 488 489 pub fn post_admin(&self, path: impl AsRef<str>) -> TestRequest { 490 self.requesta(Method::POST, path, Some("admin")) 491 } 492 493 pub fn patch_admin(&self, path: impl AsRef<str>) -> TestRequest { 494 self.requesta(Method::PATCH, path, Some("admin")) 495 } 496 497 pub fn delete_admin(&self, path: impl AsRef<str>) -> TestRequest { 498 self.requesta(Method::DELETE, path, Some("admin")) 499 } 500 501 pub async fn auth_routine(&self, method: Method, path: impl AsRef<str>, auth: Auth) { 502 let path = path.as_ref(); 503 // Bad header 504 self.request(method.clone(), path) 505 .header(AUTHORIZATION, "WTF") 506 .await 507 .assert_error(ErrorCode::GENERIC_UNAUTHORIZED); 508 509 if !matches!(auth, Auth::Token) { 510 if !matches!(auth, Auth::Optional) { 511 // No header 512 self.request(method.clone(), path) 513 .await 514 .assert_error_status( 515 ErrorCode::GENERIC_PARAMETER_MISSING, 516 StatusCode::UNAUTHORIZED, 517 ); 518 } 519 520 // Other account 521 self.requesta(method.clone(), path, Some("customer")) 522 .await 523 .assert_error(ErrorCode::GENERIC_FORBIDDEN); 524 } 525 526 match auth { 527 Auth::Admin => { 528 self.requesta(method.clone(), path, Some("merchant")) 529 .await 530 .assert_error(ErrorCode::GENERIC_FORBIDDEN); 531 } 532 Auth::UserOrAdmin | Auth::Optional => {} 533 Auth::Token => { 534 self.requesta(method.clone(), path, Some("admin")) 535 .await 536 .assert_error(ErrorCode::GENERIC_TOKEN_PERMISSION_INSUFFICIENT); 537 } 538 Auth::UserOnly => { 539 self.requesta(method.clone(), path, Some("admin")) 540 .await 541 .assert_error(ErrorCode::GENERIC_FORBIDDEN); 542 } 543 } 544 } 545 546 pub async fn fill_tan_info(&self, username: &str) { 547 self.patch_admin(format!("/accounts/{username}")) 548 .json(json!({ 549 "contact_data": { 550 "phone": format!("+{}", random_range(2000..10000)) 551 }, 552 "tan_channel": "sms" 553 })) 554 .await 555 .assert_no_content(); 556 } 557 558 pub async fn cashout(&self, amount: impl Display) { 559 let amount = format!("{}:{amount}", self.state.cfg.regional_currency); 560 self.posta("/accounts/customer/cashouts") 561 .json({ 562 json!({ 563 "request_uid": ShortHashCode::rand(), 564 "amount_debit": amount, 565 "amount_credit": self.convert(&amount).await 566 }) 567 }) 568 .await 569 .assert_ok() 570 } 571 572 pub async fn cashin(&self, amount: &str) { 573 sqlx::query("SELECT 0 FROM cashin($1, $2, $3, $4)") 574 .bind_timestamp(&Timestamp::now()) 575 .bind(ShortHashCode::rand()) 576 .bind(decimal(amount)) 577 .bind("") 578 .fetch_one(&self.state.db) 579 .await 580 .unwrap(); 581 } 582 583 pub async fn create_conversion_rate_class(&self) -> u64 { 584 self.post_admin("/conversion-rate-classes") 585 .json(json!({ 586 "name": format!("Gen class {}", Timestamp::now()) 587 })) 588 .await 589 .assert_ok_json::<ConversionRateClassResponse>() 590 .conversion_rate_class_id 591 } 592 593 pub async fn convert(&self, amount: impl Display) -> Amount { 594 self.get(format!( 595 "/conversion-info/cashout-rate?amount_debit={amount}" 596 )) 597 .await 598 .assert_ok_json::<ConversionResponse>() 599 .amount_credit 600 } 601 602 /** Set [account] debit threshold to [maxDebt] amount */ 603 pub async fn set_max_debt(&self, username: &str, amount: &str) { 604 self.patch_admin(format!("/accounts/{username}")) 605 .json(json!({ 606 "debit_threshold": format!("{}:{amount}", self.state.cfg.regional_currency) 607 })) 608 .await 609 .assert_no_content(); 610 } 611 612 /** Check [account] balance is [amount], [amount] is prefixed with + for credit and - for debit */ 613 pub async fn assert_balance(&self, username: &str, expected: &str) { 614 let res: AccountData = self 615 .get_admin(format!("/accounts/{username}")) 616 .await 617 .assert_ok_json(); 618 let Balance { 619 amount, 620 credit_debit_indicator, 621 } = res.balance; 622 let prefix = match credit_debit_indicator { 623 CreditDebitInfo::credit => "", 624 CreditDebitInfo::debit => "-", 625 }; 626 pretty_assertions::assert_eq!(format!("{prefix}{}", amount.decimal()), expected); 627 } 628 } 629 630 impl Deref for BankClient { 631 type Target = Router; 632 633 fn deref(&self) -> &Self::Target { 634 &self.server 635 } 636 } 637 638 pub struct BankTestCtx { 639 pub merchant_payto: BankPayto, 640 pub exchange_payto: BankPayto, 641 pub customer_payto: BankPayto, 642 pub unknown_payto: BankPayto, 643 pub tmp_payto: BankPayto, 644 pub admin_payto: BankPayto, 645 pub client: BankClient, 646 } 647 648 impl BankTestCtx { 649 pub async fn new(options: PgConnectOptions, conf: &str) -> Self { 650 let cfg = Config::load(CONFIG_SOURCE, Some(format!("conf/{conf}"))).unwrap(); 651 let cfg = BankCfg::parse(&cfg).unwrap(); 652 let dir = cfg.db_cfg.sql_dir.as_ref(); 653 let db = pool_with_options( 654 options, 655 "libeufin_bank", 656 PoolOptions::new().max_connections(3), 657 ) 658 .await 659 .unwrap(); 660 let mut conn = db.acquire().await.unwrap(); 661 dbinit(&mut conn, dir, "libeufin-bank", true).await.unwrap(); 662 dbinit(&mut conn, dir, "libeufin-nexus", true) 663 .await 664 .unwrap(); 665 let procedure = 666 std::fs::read_to_string(dir.join("libeufin-conversion-setup.sql")).unwrap(); 667 sqlx::raw_sql(AssertSqlSafe(procedure)) 668 .execute(&mut *conn) 669 .await 670 .unwrap(); 671 drop(conn); 672 673 let state = Arc::new(BankState::start(db.clone(), cfg).await); 674 let server = bank_api(state.clone()).finalize(); 675 676 let merchant_payto = db::account::create( 677 &db, 678 &state.cfg.ctx, 679 &state.cfg.pw_crypto, 680 "merchant", 681 "merchant-password", 682 "Merchant", 683 None, 684 None, 685 None, 686 &rand_iban_payto().into_inner().into(), 687 false, 688 false, 689 Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), 690 Amount::zero(&state.cfg.regional_currency), 691 &[], 692 false, 693 None, 694 ) 695 .await 696 .unwrap() 697 .assert_success(); 698 let exchange_payto = db::account::create( 699 &db, 700 &state.cfg.ctx, 701 &state.cfg.pw_crypto, 702 "exchange", 703 "exchange-password", 704 "Exchange", 705 None, 706 None, 707 None, 708 &rand_iban_payto().into_inner().into(), 709 false, 710 true, 711 Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), 712 Amount::zero(&state.cfg.regional_currency), 713 &[], 714 false, 715 None, 716 ) 717 .await 718 .unwrap() 719 .assert_success(); 720 let customer_payto = db::account::create( 721 &db, 722 &state.cfg.ctx, 723 &state.cfg.pw_crypto, 724 "customer", 725 "customer-password", 726 "Customer", 727 None, 728 None, 729 None, 730 &rand_iban_payto().into_inner().into(), 731 false, 732 false, 733 Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), 734 Amount::zero(&state.cfg.regional_currency), 735 &[], 736 false, 737 None, 738 ) 739 .await 740 .unwrap() 741 .assert_success(); 742 743 let res = create_admin_account(&db, &state.cfg, Some("admin-password")) 744 .await 745 .unwrap(); 746 747 let admin_payto = match res { 748 CreationResult::Success(payto) => payto, 749 _ => unreachable!(), 750 }; 751 752 let mut ctx = BankTestCtx { 753 merchant_payto: merchant_payto.into(), 754 exchange_payto: exchange_payto.into(), 755 customer_payto: customer_payto.into(), 756 unknown_payto: rand_iban_payto().convert(), 757 tmp_payto: rand_iban_payto().convert(), 758 admin_payto: admin_payto.into(), 759 client: BankClient { 760 server, 761 tokens: BTreeMap::new(), 762 state, 763 }, 764 }; 765 ctx.client 766 .cache_tokens(&["admin", "merchant", "exchange", "customer"]) 767 .await; 768 769 if ctx.state.cfg.fiat.is_some() { 770 // Set conversion rates 771 ctx.post_admin("/conversion-info/conversion-rate") 772 .json(json!({ 773 "cashin_ratio" :"0.8", 774 "cashin_fee" :"KUDOS:0.02", 775 "cashin_tiny_amount" :"KUDOS:0.01", 776 "cashin_rounding_mode" :"nearest", 777 "cashin_min_amount" :"EUR:0", 778 "cashout_ratio" :"1.26", 779 "cashout_fee" :"EUR:0.003", 780 "cashout_tiny_amount" :"EUR:0.01", 781 "cashout_rounding_mode" :"zero", 782 "cashout_min_amount" :"KUDOS:0.1" 783 })) 784 .await 785 .assert_no_content(); 786 ctx.create_conversion_rate_class().await; 787 } 788 789 ctx 790 } 791 792 pub async fn swap_cfg(mut self, conf: &str) -> Self { 793 let cfg = Config::load(CONFIG_SOURCE, Some(format!("conf/{conf}"))).unwrap(); 794 795 let metrics = Metrics::default(); 796 let state = Arc::new(BankState { 797 db: self.state.db.clone(), 798 cfg: BankCfg::parse(&cfg).unwrap(), 799 tx_channel: self.state.tx_channel.clone(), 800 taler_out_channel: self.state.taler_out_channel.clone(), 801 taler_in_channel: self.state.taler_in_channel.clone(), 802 revenue_channel: self.state.revenue_channel.clone(), 803 withdrawal_channel: self.state.withdrawal_channel.clone(), 804 registry: metrics.registry(), 805 metrics, 806 }); 807 self.client.server = bank_api(state.clone()); 808 self.state = state; 809 self 810 } 811 812 pub async fn fill_cashout_info(&self, username: &str) { 813 self.patch_admin(format!("/accounts/{username}")) 814 .json(json!({ 815 "cashout_payto_uri": self.unknown_payto, 816 })) 817 .await 818 .assert_no_content(); 819 } 820 821 pub async fn tmp_payto(&mut self) -> PaytoURI { 822 self.tmp_payto = rand_iban_payto().convert(); 823 self.tmp_payto.as_uri() 824 } 825 826 pub async fn tx_s(&self, from: &str, amount: &str, to: &str, subject: impl Display) { 827 let payto = match to { 828 "admin" => &self.admin_payto, 829 "merchant" => &self.merchant_payto, 830 "customer" => &self.customer_payto, 831 "exchange" => &self.exchange_payto, 832 _ => &self.tmp_payto, 833 }; 834 self.posta(format!("/accounts/{from}/transactions")) 835 .json(json!({ 836 "payto_uri": format!("{payto}?message={subject}"), 837 "amount": format!("{}:{amount}", self.state.cfg.regional_currency), 838 })) 839 .await 840 .maybe_challenge(self) 841 .await 842 .assert_ok(); 843 } 844 845 pub async fn tx(&self, from: &str, amount: &str, to: &str) { 846 self.tx_s(from, amount, to, "payout").await 847 } 848 849 pub async fn transfer( 850 &self, 851 amount: &str, 852 payto: &BankPayto, 853 metadata: Option<CompactString>, 854 ) { 855 self.posta("/accounts/exchange/taler-wire-gateway/transfer") 856 .json({ 857 json!({ 858 "request_uid": HashCode::rand(), 859 "amount": format!("{}:{amount}", self.state.cfg.regional_currency), 860 "exchange_base_url": "http://exchange.example.com/", 861 "wtid": ShortHashCode::rand(), 862 "credit_account": payto, 863 "metadata": metadata, 864 }) 865 }) 866 .await 867 .assert_ok() 868 } 869 870 /** Perform a taler incoming transaction of [amount] from merchant to exchange */ 871 pub async fn add_incoming(&self, amount: &str) { 872 self.post_admin("/accounts/exchange/taler-wire-gateway/admin/add-incoming") 873 .json({ 874 json!({ 875 "amount": format!("{}:{amount}", self.state.cfg.regional_currency), 876 "reserve_pub": EddsaPublicKey::rand(), 877 "debit_account": self.merchant_payto 878 }) 879 }) 880 .await 881 .assert_ok() 882 } 883 884 pub async fn add_kyc(&self, amount: &str) { 885 self.post_admin("/accounts/exchange/taler-wire-gateway/admin/add-kycauth") 886 .json({ 887 json!({ 888 "amount": format!("{}:{amount}", self.state.cfg.regional_currency), 889 "account_pub": EddsaPublicKey::rand(), 890 "debit_account": self.merchant_payto 891 }) 892 }) 893 .await 894 .assert_ok() 895 } 896 897 pub async fn withdrawal(&self, amount: &str) { 898 let uuid = self 899 .posta("/accounts/merchant/withdrawals") 900 .json({ 901 json!({ 902 "amount": format!("{}:{amount}", self.state.cfg.regional_currency) 903 }) 904 }) 905 .await 906 .assert_ok_json::<BankAccountCreateWithdrawalResponse>() 907 .withdrawal_id; 908 self.withdraw_select(uuid).await; 909 self.posta(format!("/accounts/merchant/withdrawals/{uuid}/confirm")) 910 .json(json!({})) 911 .await 912 .assert_no_content(); 913 } 914 915 pub async fn withdraw_select(&self, uuid: Uuid) -> EddsaPublicKey { 916 let key = EddsaPublicKey::rand(); 917 self.post(format!("/taler-integration/withdrawal-operation/{uuid}")) 918 .json(json!({ 919 "reserve_pub": key, 920 "selected_exchange": self.exchange_payto 921 })) 922 .await 923 .assert_ok(); 924 key 925 } 926 } 927 928 impl Deref for BankTestCtx { 929 type Target = BankClient; 930 931 fn deref(&self) -> &Self::Target { 932 &self.client 933 } 934 } 935 936 impl DerefMut for BankTestCtx { 937 fn deref_mut(&mut self) -> &mut Self::Target { 938 &mut self.client 939 } 940 } 941 942 pub async fn bank_setup(db: PgConnectOptions) -> BankTestCtx { 943 bank_setup_conf(db, "test.conf").await 944 } 945 946 pub async fn bank_setup_conf(db: PgConnectOptions, conf: &str) -> BankTestCtx { 947 BankTestCtx::new(db, conf).await 948 } 949 950 pub fn tan_code(info: &str) -> Option<CompactString> { 951 let path = format!("/tmp/tan-{}.txt", info); 952 let code = match std::fs::read_to_string(&path) { 953 Ok(f) => f, 954 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None, 955 Err(e) => panic!("{:?}", e), 956 }; 957 std::fs::remove_file(path).unwrap(); 958 Some(code.split(' ').next().unwrap().into()) 959 } 960 961 pub trait MfaRequest: Sized { 962 fn assert_challenge_check( 963 &self, 964 ctx: &BankClient, 965 check: impl AsyncFnOnce(&ChallengeResponse), 966 ) -> impl std::future::Future<Output = Self>; 967 968 fn assert_challenge(&self, ctx: &BankClient) -> impl std::future::Future<Output = Self> { 969 self.assert_challenge_check(ctx, async |_| {}) 970 } 971 972 fn maybe_challenge(self, ctx: &BankClient) -> impl std::future::Future<Output = Self>; 973 } 974 975 impl MfaRequest for TestResponse { 976 async fn assert_challenge_check( 977 &self, 978 ctx: &BankClient, 979 check: impl AsyncFnOnce(&ChallengeResponse), 980 ) -> TestResponse { 981 let res: ChallengeResponse = self.assert_accepted_json(); 982 let username = self.uri.path().split('/').nth(2).unwrap(); 983 984 let challenges = if res.combi_and { 985 &res.challenges 986 } else { 987 std::slice::from_ref(res.challenges.choose(&mut rand::rng()).unwrap()) 988 }; 989 990 for challenge in challenges { 991 ctx.post(format!( 992 "/accounts/{username}/challenge/{}", 993 challenge.challenge_id 994 )) 995 .await 996 .assert_ok(); 997 } 998 check(&res).await; 999 1000 for challenge in challenges { 1001 let info = if challenge.tan_info == "REDACTED" { 1002 match challenge.tan_channel { 1003 TanChannel::sms => "+4567", 1004 TanChannel::email => "test@gmail.com", 1005 } 1006 } else { 1007 &challenge.tan_info 1008 }; 1009 let code = tan_code(info).unwrap(); 1010 ctx.post(format!( 1011 "/accounts/{username}/challenge/{}/confirm", 1012 challenge.challenge_id 1013 )) 1014 .json(json!({ "tan": code })) 1015 .await 1016 .assert_no_content(); 1017 } 1018 // Recover body from request 1019 let ids = res 1020 .challenges 1021 .into_iter() 1022 .map(|it| it.challenge_id) 1023 .join_compact(", "); 1024 let req = ctx 1025 .request(self.method.clone(), self.uri.path()) 1026 .header(TALER_CHALLENGE_IDS, ids.to_string()); 1027 if let Some(auth) = self.req_headers.get(AUTHORIZATION) { 1028 req.header(AUTHORIZATION, auth) 1029 } else { 1030 req 1031 } 1032 .raw_json(self.req_body.clone()) 1033 .await 1034 } 1035 1036 async fn maybe_challenge(self, ctx: &BankClient) -> Self { 1037 if self.status == StatusCode::ACCEPTED { 1038 self.assert_challenge(ctx).await 1039 } else { 1040 self 1041 } 1042 } 1043 } 1044 1045 #[db_test(raw)] 1046 async fn corebank(db: PgConnectOptions) { 1047 use crate::api::MonitorResponse; 1048 let ctx = bank_setup(db).await; 1049 1050 ctx.auth_routine(Method::GET, "/monitor", Auth::Admin).await; 1051 1052 ctx.get("/config").await.assert_ok(); 1053 1054 ctx.get_admin("/monitor?timeframe=day&which=25") 1055 .await 1056 .assert_ok_json::<MonitorResponse>(); 1057 ctx.get_admin("/monitor?timeframe=day=which=25") 1058 .await 1059 .assert_error(ErrorCode::GENERIC_PARAMETER_MALFORMED); 1060 1061 ctx.fill_cashout_info("customer").await; 1062 ctx.cashout("1").await; 1063 ctx.get_admin("/monitor") 1064 .await 1065 .assert_ok_json::<MonitorResponse>(); 1066 } 1067 }