libeufin

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

commit 40f381ca93fa66176452caeb5909d7ee8d9c0ca6
parent ec8de833da90f8d68b06beb157194e0b58f06408
Author: Antoine A <>
Date:   Thu,  7 May 2026 18:29:01 +0200

bank: finish reconfig

Diffstat:
Mcrates/libeufin-bank/src/api.rs | 258+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mcrates/libeufin-bank/src/api/account.rs | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mcrates/libeufin-bank/src/db/account.rs | 5+++++
3 files changed, 267 insertions(+), 156 deletions(-)

diff --git a/crates/libeufin-bank/src/api.rs b/crates/libeufin-bank/src/api.rs @@ -43,7 +43,7 @@ pub mod test { use compact_str::CompactString; use jiff::Timestamp; use rand::{random_range, seq::IndexedRandom}; - use sqlx::{PgPool, Postgres, pool::PoolConnection}; + use sqlx::{PgPool, Pool, Postgres, pool::PoolConnection}; use taler_api::api::TalerRouter; use taler_common::{ config::Config, @@ -96,6 +96,145 @@ pub mod test { } impl BankTestCtx { + pub async fn new(conf: &str, db: Pool<Postgres>) -> Self { + let cfg = Config::from_file( + CONFIG_SOURCE, + Some(format!("../../libeufin-bank/conf/{conf}")), + ) + .unwrap(); + + let state = Arc::new(BankState { + db: db.clone(), + cfg: BankCfg::parse(cfg).unwrap(), + }); + let server = token_api() + .merge(account_api()) + .merge(tx_api()) + .merge(tan_api()) + .merge(conversion_api()) + .with_state(state.clone()) + .finalize(); + + let merchant_payto = db::account::create( + &db, + &state.cfg.ctx, + &state.cfg.pw_crypto, + "merchant", + "merchant-password", + "Merchant", + None, + None, + None, + LibeufinId::IBAN(rand_iban_payto().into_inner()), + false, + false, + Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), + Amount::zero(&state.cfg.regional_currency), + &[], + false, + None, + ) + .await + .unwrap() + .assert_success(); + let exchange_payto = db::account::create( + &db, + &state.cfg.ctx, + &state.cfg.pw_crypto, + "exchange", + "exchange-password", + "Exchange", + None, + None, + None, + LibeufinId::IBAN(rand_iban_payto().into_inner()), + false, + false, + Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), + Amount::zero(&state.cfg.regional_currency), + &[], + false, + None, + ) + .await + .unwrap() + .assert_success(); + let customer_payto = db::account::create( + &db, + &state.cfg.ctx, + &state.cfg.pw_crypto, + "customer", + "customer-password", + "Customer", + None, + None, + None, + LibeufinId::IBAN(rand_iban_payto().into_inner()), + false, + false, + Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), + Amount::zero(&state.cfg.regional_currency), + &[], + false, + None, + ) + .await + .unwrap() + .assert_success(); + + let res = create_admin_account(&db, &state.cfg, Some("admin-password")) + .await + .unwrap(); + + let admin_payto = match res { + CreationResult::Success(payto) => payto, + _ => unreachable!(), + }; + + let mut ctx = BankTestCtx { + merchant_payto, + exchange_payto, + customer_payto, + unknown_payto: FullBankPayto::new( + LibeufinId::IBAN(rand_iban_payto().into_inner()), + "Unknown", + ), + tmp_payto: FullBankPayto::new( + LibeufinId::IBAN(rand_iban_payto().into_inner()), + "Unknown", + ), + admin_payto: admin_payto, + server, + db, + tokens: BTreeMap::new(), + }; + ctx.cache_tokens(&["admin", "merchant", "exchange", "customer"]) + .await; + + ctx + } + + pub fn swap_cfg(mut self, conf: &str) -> Self { + let cfg = Config::from_file( + CONFIG_SOURCE, + Some(format!("../../libeufin-bank/conf/{conf}")), + ) + .unwrap(); + + let state = Arc::new(BankState { + db: self.db.clone(), + cfg: BankCfg::parse(cfg).unwrap(), + }); + self.server = token_api() + .merge(account_api()) + .merge(tx_api()) + .merge(tan_api()) + .merge(conversion_api()) + .with_state(state.clone()) + .finalize(); + self + } + fn pw_auth(req: TestRequest, username: Option<&str>) -> TestRequest { let username: CompactString = username .unwrap_or_else(|| Self::extract_username(req.url.path())) @@ -288,122 +427,7 @@ pub mod test { pub async fn bank_setup_conf(conf: &str) -> BankTestCtx { let (_, db) = db_setup().await; - let cfg = Config::from_file( - CONFIG_SOURCE, - Some(format!("../../libeufin-bank/conf/{conf}")), - ) - .unwrap(); - - let state = Arc::new(BankState { - db: db.clone(), - cfg: BankCfg::parse(cfg).unwrap(), - }); - - let server = token_api() - .merge(account_api()) - .merge(tx_api()) - .merge(tan_api()) - .merge(conversion_api()) - .with_state(state.clone()) - .finalize(); - - let merchant_payto = db::account::create( - &db, - &state.cfg.ctx, - &state.cfg.pw_crypto, - "merchant", - "merchant-password", - "Merchant", - None, - None, - None, - LibeufinId::IBAN(rand_iban_payto().into_inner()), - false, - false, - Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), - Amount::zero(&state.cfg.regional_currency), - &[], - false, - None, - ) - .await - .unwrap() - .assert_success(); - let exchange_payto = db::account::create( - &db, - &state.cfg.ctx, - &state.cfg.pw_crypto, - "exchange", - "exchange-password", - "Exchange", - None, - None, - None, - LibeufinId::IBAN(rand_iban_payto().into_inner()), - false, - false, - Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), - Amount::zero(&state.cfg.regional_currency), - &[], - false, - None, - ) - .await - .unwrap() - .assert_success(); - let customer_payto = db::account::create( - &db, - &state.cfg.ctx, - &state.cfg.pw_crypto, - "customer", - "customer-password", - "Customer", - None, - None, - None, - LibeufinId::IBAN(rand_iban_payto().into_inner()), - false, - false, - Decimal::new(10, 0).to_amount(&state.cfg.regional_currency), - Amount::zero(&state.cfg.regional_currency), - &[], - false, - None, - ) - .await - .unwrap() - .assert_success(); - - let res = create_admin_account(&db, &state.cfg, Some("admin-password")) - .await - .unwrap(); - - let admin_payto = match res { - CreationResult::Success(payto) => payto, - _ => unreachable!(), - }; - - let mut ctx = BankTestCtx { - merchant_payto, - exchange_payto, - customer_payto, - unknown_payto: FullBankPayto::new( - LibeufinId::IBAN(rand_iban_payto().into_inner()), - "Unknown", - ), - tmp_payto: FullBankPayto::new( - LibeufinId::IBAN(rand_iban_payto().into_inner()), - "Unknown", - ), - admin_payto: admin_payto, - server, - db, - tokens: BTreeMap::new(), - }; - ctx.cache_tokens(&["admin", "merchant", "exchange", "customer"]) - .await; - - ctx + BankTestCtx::new(conf, db).await } pub fn tan_code(info: &str) -> Option<CompactString> { diff --git a/crates/libeufin-bank/src/api/account.rs b/crates/libeufin-bank/src/api/account.rs @@ -490,6 +490,10 @@ pub async fn create_account( } else if !channels.is_empty() { return Err(failure_code(ErrorCode::BANK_NON_ADMIN_SET_TAN_CHANNEL)); } + } else { + if let Some(amount) = req.debit_threshold { + cfg.check_regio(&amount)?; + } } for channel in channels { @@ -645,8 +649,10 @@ pub async fn patch_account( is_admin: bool, is2fa: bool, ) -> ApiResult<PatchResult> { - // TODO check regional currency - // + if let Some(amount) = req.debit_threshold { + cfg.check_regio(&amount)?; + } + if username == "admin" && req.is_public == Some(true) { return Err(failure_status( ErrorCode::END, @@ -960,6 +966,11 @@ pub mod test { })) .await .assert_error(ErrorCode::BANK_PASSWORD_TOO_LONG); + // Check currency + ctx.post_admin("/accounts") + .json(json!(req + { "debit_threshold": "EUR:100" })) + .await + .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); // Check cashout payto receiver name logic ctx.post("/accounts") @@ -1096,7 +1107,7 @@ pub mod test { acc ); - let ctx = bank_setup_conf("test_bonus.conf").await; + let ctx = ctx.swap_cfg("test_bonus.conf"); // Create bonus { let req = json!({ @@ -1128,12 +1139,12 @@ pub mod test { } // Restricted account creation - let ctx = bank_setup_conf("test_restrict.conf").await; + let ctx = ctx.swap_cfg("test_restrict.conf"); ctx.auth_routine(Method::POST, "/accounts", Auth::User) .await; ctx.post_admin("/accounts") .json(json!({ - "username": "foo", + "username": "foobar", "password": "password-xyz", "name": "Mallory" })) @@ -1141,7 +1152,7 @@ pub mod test { .assert_ok(); // Unsupported tan - let ctx = bank_setup_conf("test_tan_err.conf").await; + let ctx = ctx.swap_cfg("test_tan_err.conf"); ctx.post_admin("/accounts") .json(json!({ "username": "foo", @@ -1153,7 +1164,7 @@ pub mod test { .assert_error(ErrorCode::BANK_TAN_CHANNEL_NOT_SUPPORTED); // No password check - let ctx = bank_setup_conf("test_no_password_check.conf").await; + let ctx = ctx.swap_cfg("test_no_password_check.conf"); // Short password ctx.post("/accounts") .json(json!({ @@ -1220,6 +1231,7 @@ pub mod test { "cashout_payto_uri": cashout, "name": "Roger", "is_public": true, + "is_taler_exchange": true, "contact_data": { "phone": "+99", "email": "foo@example.com" @@ -1235,8 +1247,70 @@ pub mod test { .await .assert_no_content(); + // Check admin only + check_admin_only( + &ctx, + json!(req + { "debit_threshold": "KUDOS:100" }), + ErrorCode::BANK_NON_ADMIN_PATCH_DEBT_LIMIT, + ) + .await; + let class_id = ctx.create_conversion_rate_class().await; + check_admin_only( + &ctx, + json!(req + { "conversion_rate_class_id": class_id }), + ErrorCode::BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS, + ) + .await; + + // Check unknown conversion rate class + ctx.patch_admin("/accounts/merchant") + .json(json!(req + { "conversion_rate_class_id": 42 })) + .await + .assert_error(ErrorCode::BANK_CONVERSION_RATE_CLASS_UNKNOWN); + + // Check currency + ctx.patch_admin("/accounts/merchant") + .json(json!(req + { "debit_threshold": "EUR:100" })) + .await + .assert_error(ErrorCode::GENERIC_CURRENCY_MISMATCH); + // Check patch - // TODO check set taler exchange + let acc: AccountData = ctx.geta("/accounts/merchant").await.assert_ok_json(); + let expected = AccountData { + name: "Roger".into(), + balance: Balance { + amount: amount("KUDOS:0"), + credit_debit_indicator: CreditDebitInfo::credit, + }, + cashout_payto_uri: Some(acc.cashout_payto_uri.clone().unwrap()), + contact_data: ChallengeContactData { + phone: Maybe::Some("+99".into()), + email: Maybe::Some("foo@example.com".into()), + }, + conversion_rate: Some(acc.conversion_rate.clone().unwrap()), + conversion_rate_class_id: Some(1), + debit_threshold: acc.debit_threshold, + is_locked: false, + is_public: true, + is_taler_exchange: true, + payto_uri: ctx.merchant_payto.as_full_uri("Roger"), + status: AccountStatus::active, + tan_channel: None, + tan_channels: Vec::new(), + }; + pretty_assertions::assert_eq!(acc, expected); + + // Check keep values when there is no changes + ctx.patcha("/accounts/merchant") + .json(json!({})) + .await + .assert_no_content(); + pretty_assertions::assert_eq!( + ctx.geta("/accounts/merchant") + .await + .assert_ok_json::<AccountData>(), + expected + ); // Admin cannot be public ctx.patcha("/accounts/admin") @@ -1302,47 +1376,55 @@ pub mod test { } // Check 2FA - ctx.fill_tan_info("merchant").await; - ctx.patcha("/accounts/merchant") - .json(json!({ "is_public": false })) + ctx.fill_tan_info("customer").await; + ctx.patcha("/accounts/customer") + .json(json!({ "is_public": true })) .await .assert_challenge_check(&ctx, async |_| { - let acc: AccountData = ctx.geta("/accounts/merchant").await.assert_ok_json(); - assert_eq!(acc.is_public, true); + let acc: AccountData = ctx.geta("/accounts/customer").await.assert_ok_json(); + assert_eq!(acc.is_public, false); }) .await .assert_no_content(); - let acc: AccountData = ctx.geta("/accounts/merchant").await.assert_ok_json(); - assert_eq!(acc.is_public, false); - } + let acc: AccountData = ctx.geta("/accounts/customer").await.assert_ok_json(); + assert_eq!(acc.is_public, true); - #[tokio::test] - async fn reconfig_restricted() { - let ctx = bank_setup_conf("test_restrict.conf").await; - // Check restricted - check_admin_only( - &ctx, - json!({ "name": "Another Foo" }), - ErrorCode::BANK_NON_ADMIN_PATCH_LEGAL_NAME, - ) - .await; - check_admin_only( - &ctx, - json!({ "cashout_payto_uri": rand_iban_payto() }), - ErrorCode::BANK_NON_ADMIN_PATCH_CASHOUT, - ) - .await; + // Restriction + let ctx = ctx.swap_cfg("test_restrict.conf"); + { + check_admin_only( + &ctx, + json!({ "name": "Another Foo" }), + ErrorCode::BANK_NON_ADMIN_PATCH_LEGAL_NAME, + ) + .await; + check_admin_only( + &ctx, + json!({ "cashout_payto_uri": rand_iban_payto() }), + ErrorCode::BANK_NON_ADMIN_PATCH_CASHOUT, + ) + .await; - // Check idempotent - let acc: AccountData = ctx.geta("/accounts/merchant").await.assert_ok_json(); - ctx.patcha("/accounts/merchant") + // Check idempotent + let acc: AccountData = ctx.geta("/accounts/merchant").await.assert_ok_json(); + ctx.patcha("/accounts/merchant") + .json(json!({ + "name": acc.name, + "cashout_payto_uri": acc.cashout_payto_uri, + "debit_threshold": acc.debit_threshold + })) + .await + .assert_no_content(); + } + + // Unsupported tan + let ctx = ctx.swap_cfg("test_tan_err.conf"); + ctx.patcha("/accounts/customer") .json(json!({ - "name": acc.name, - "cashout_payto_uri": acc.cashout_payto_uri, - "debit_threshold": acc.debit_threshold + "tan_channel": "email" })) .await - .assert_no_content(); + .assert_error(ErrorCode::BANK_TAN_CHANNEL_NOT_SUPPORTED); } } diff --git a/crates/libeufin-bank/src/db/account.rs b/crates/libeufin-bank/src/db/account.rs @@ -412,6 +412,11 @@ pub async fn reconfig( if let Some(v) = is_public { separated.push("is_public=").push_bind_unseparated(v); } + if let Some(v) = is_taler_exchange { + separated + .push("is_taler_exchange=") + .push_bind_unseparated(v); + } if let Some(v) = debit_threshold { separated.push("max_debt=").push_bind_unseparated(v); }