libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

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                         &params,
    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 }