commit 40f381ca93fa66176452caeb5909d7ee8d9c0ca6
parent ec8de833da90f8d68b06beb157194e0b58f06408
Author: Antoine A <>
Date: Thu, 7 May 2026 18:29:01 +0200
bank: finish reconfig
Diffstat:
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);
}