libeufin

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

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                         &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::{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 }