libeufin

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

commit 21aee9fc8810296ccf5cbebb8d28e11030fa7d5d
parent 46e8184ce90eeeeb2a4f6e15a5116de977e67d4a
Author: Antoine A <>
Date:   Tue, 16 Jun 2026 12:34:06 +0200

common: add integration tests

Diffstat:
MCargo.lock | 86+++++++++++++++++++++++++++++++++++++++----------------------------------------
MCargo.toml | 11+++++------
Mlibeufin-bank/Cargo.toml | 9+++++----
Mlibeufin-bank/src/api.rs | 518++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mlibeufin-bank/src/bench.rs | 3++-
Mlibeufin-bank/src/db.rs | 53++++++++++++++++++++++++++++++++++++++++++++++++++---
Mlibeufin-bank/src/lib.rs | 38++++----------------------------------
Dlibeufin-common/src/main/kotlin/TalerCommon.kt | 781-------------------------------------------------------------------------------
Mlibeufin-ebics/Cargo.toml | 9+++------
Mlibeufin-ebisync/Cargo.toml | 5+----
Mlibeufin-nexus/Cargo.toml | 4+---
Mlibeufin-nexus/src/db.rs | 83++++++++++++++++++++++++++++++++-----------------------------------------------
Mlibeufin-nexus/src/db/initiated.rs | 2+-
Dlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt | 254-------------------------------------------------------------------------------
Mtestbench/Cargo.toml | 12+++++++++---
Mtestbench/conf/integration.conf | 2--
Atestbench/src/integration.rs | 605+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atestbench/src/lib.rs | 360+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtestbench/src/main.rs | 220+++++++++++++------------------------------------------------------------------
19 files changed, 1434 insertions(+), 1621 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -383,9 +383,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "jobserver", @@ -805,9 +805,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive_more" @@ -1315,7 +1312,7 @@ dependencies = [ [[package]] name = "http-client" version = "1.5.0" -source = "git+git://git.taler.net/taler-rust.git/#4d358b00b0bb55d20f58d18c3e2cac0a495cdd23" +source = "git+git://git.taler.net/taler-rust.git/#9db73672548899f1626c9c8ab854f4b0b26a02bc" dependencies = [ "compact_str", "futures-util", @@ -1699,9 +1696,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.100" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", @@ -1826,10 +1823,8 @@ dependencies = [ "jiff", "libeufin-ebics", "pretty_assertions", - "rand 0.10.1", "reqwest", "serde", - "serde_json", "shlex", "sqlx", "taler-api", @@ -1841,7 +1836,6 @@ dependencies = [ "tokio", "tower-http", "tracing", - "uuid", "zip 8.6.0", ] @@ -1857,7 +1851,6 @@ dependencies = [ "jiff", "libeufin-ebics", "regex", - "serde", "serde_json", "shlex", "sqlx", @@ -2007,9 +2000,9 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "mimalloc" @@ -3120,9 +3113,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -3470,7 +3463,7 @@ dependencies = [ [[package]] name = "taler-api" version = "1.5.0" -source = "git+git://git.taler.net/taler-rust.git/#4d358b00b0bb55d20f58d18c3e2cac0a495cdd23" +source = "git+git://git.taler.net/taler-rust.git/#9db73672548899f1626c9c8ab854f4b0b26a02bc" dependencies = [ "aws-lc-rs", "axum", @@ -3497,12 +3490,12 @@ dependencies = [ [[package]] name = "taler-build" version = "1.5.0" -source = "git+git://git.taler.net/taler-rust.git/#4d358b00b0bb55d20f58d18c3e2cac0a495cdd23" +source = "git+git://git.taler.net/taler-rust.git/#9db73672548899f1626c9c8ab854f4b0b26a02bc" [[package]] name = "taler-common" version = "1.5.0" -source = "git+git://git.taler.net/taler-rust.git/#4d358b00b0bb55d20f58d18c3e2cac0a495cdd23" +source = "git+git://git.taler.net/taler-rust.git/#9db73672548899f1626c9c8ab854f4b0b26a02bc" dependencies = [ "anyhow", "aws-lc-rs", @@ -3531,7 +3524,7 @@ dependencies = [ [[package]] name = "taler-macros" version = "1.5.0" -source = "git+git://git.taler.net/taler-rust.git/#4d358b00b0bb55d20f58d18c3e2cac0a495cdd23" +source = "git+git://git.taler.net/taler-rust.git/#9db73672548899f1626c9c8ab854f4b0b26a02bc" dependencies = [ "proc-macro2", "quote", @@ -3541,7 +3534,7 @@ dependencies = [ [[package]] name = "taler-test-utils" version = "1.5.0" -source = "git+git://git.taler.net/taler-rust.git/#4d358b00b0bb55d20f58d18c3e2cac0a495cdd23" +source = "git+git://git.taler.net/taler-rust.git/#9db73672548899f1626c9c8ab854f4b0b26a02bc" dependencies = [ "aws-lc-rs", "axum", @@ -3583,13 +3576,19 @@ name = "testbench" version = "1.5.0" dependencies = [ "anyhow", + "axum", "clap", + "http-client", "jiff", + "libeufin-bank", "libeufin-ebics", "libeufin-ebisync", "libeufin-nexus", "nix", "owo-colors", + "serde_json", + "sqlx", + "taler-api", "taler-common", "taler-test-utils", "tokio", @@ -3628,12 +3627,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -3643,15 +3641,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" dependencies = [ "num-conv", "time-core", @@ -4087,9 +4085,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen 0.57.1", ] @@ -4111,9 +4109,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.123" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -4124,9 +4122,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.73" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ "js-sys", "wasm-bindgen", @@ -4134,9 +4132,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.123" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4144,9 +4142,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.123" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -4157,9 +4155,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.123" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] @@ -4200,9 +4198,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.100" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4793,9 +4791,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml @@ -42,11 +42,11 @@ uuid = { version = "1.0", features = ["v4", "fast-rng", "serde"] } rand = "0.10" pretty_assertions = "1" dialoguer = "0.12" -zip = { version = "8.5", default-features = false, features = [ - "deflate-flate2-zlib-rs", -] } -tower-http = { version = "0.6", features = ["fs"]} +zip = { version = "8.5", default-features = false, features = ["deflate-flate2-zlib-rs"] } +tower-http = { version = "0.6", features = ["fs"] } shlex = "2.0" +tempfile = "3" +url = "2.5" taler-common = { git = "git://git.taler.net/taler-rust.git/" } taler-api = { git = "git://git.taler.net/taler-rust.git/" } taler-build = { git = "git://git.taler.net/taler-rust.git/" } @@ -58,4 +58,4 @@ http-client = { git = "git://git.taler.net/taler-rust.git/" } #taler-build = { path = "../taler-rust/common/taler-build" } #taler-test-utils = { path = "../taler-rust/common/taler-test-utils" } #taler-macros = { path = "../taler-rust/common/taler-macros" } -#http-client = { path = "../taler-rust/common/http-client" } -\ No newline at end of file +#http-client = { path = "../taler-rust/common/http-client" } diff --git a/libeufin-bank/Cargo.toml b/libeufin-bank/Cargo.toml @@ -7,6 +7,9 @@ homepage.workspace = true repository.workspace = true license-file.workspace = true +[features] +test-utils = [] + [dependencies] tokio.workspace = true tracing.workspace = true @@ -28,10 +31,8 @@ axum.workspace = true rand.workspace = true dialoguer.workspace = true tower-http.workspace = true +url.workspace = true +pretty_assertions.workspace = true futures = "0.3" -url = "2.5" regex = "1.12" bcrypt = "0.19.0" - -[dev-dependencies] -pretty_assertions.workspace = true diff --git a/libeufin-bank/src/api.rs b/libeufin-bank/src/api.rs @@ -239,9 +239,14 @@ pub fn bank_api(state: Arc<BankState>) -> Router { .finalize() } -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] pub mod test { - use std::{collections::BTreeMap, fmt::Display, ops::Deref, sync::Arc}; + use std::{ + collections::BTreeMap, + fmt::Display, + ops::{Deref, DerefMut}, + sync::Arc, + }; use axum::{ Router, @@ -252,6 +257,7 @@ pub mod test { use compact_str::{CompactString, CompactStringExt}; use jiff::Timestamp; use rand::{random_range, seq::IndexedRandom}; + use sqlx::PgPool; use taler_api::db::BindHelper; use taler_common::{ api::{EddsaPublicKey, HashCode, ShortHashCode}, @@ -296,172 +302,34 @@ pub mod test { Token, } - pub struct BankTestCtx { - pub merchant_payto: BankPayto, - pub exchange_payto: BankPayto, - pub customer_payto: BankPayto, - pub unknown_payto: BankPayto, - pub tmp_payto: BankPayto, - pub admin_payto: BankPayto, - pub server: Router, - pub state: Arc<BankState>, - tokens: BTreeMap<CompactString, String>, + fn pw_auth(req: TestRequest, username: Option<&str>) -> TestRequest { + let username: CompactString = username + .unwrap_or_else(|| extract_username(req.url.path())) + .into(); + req.basic_auth(&username, &format!("{username}-password")) } - impl BankTestCtx { - pub async fn new(conf: &str) -> Self { - let cfg = Config::from_file(CONFIG_SOURCE, Some(format!("conf/{conf}"))).unwrap(); - let cfg = BankCfg::parse(&cfg).unwrap(); - let dir = cfg.db_cfg.sql_dir.as_ref(); - let (mut conn, db) = db_test_setup_manual(dir, "libeufin-bank").await; - dbinit(&mut conn, dir, "libeufin-bank", true).await.unwrap(); - dbinit(&mut conn, dir, "libeufin-nexus", true) - .await - .unwrap(); - let procedure = - std::fs::read_to_string(dir.join("libeufin-conversion-setup.sql")).unwrap(); - sqlx::raw_sql(&procedure).execute(&mut *conn).await.unwrap(); - drop(conn); - - let state = Arc::new(BankState::start(db.clone(), cfg).await); - let server = bank_api(state.clone()); - - let merchant_payto = db::account::create( - &db, - &state.cfg.ctx, - &state.cfg.pw_crypto, - "merchant", - "merchant-password", - "Merchant", - None, - None, - None, - &rand_iban_payto().into_inner().into(), - 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, - &rand_iban_payto().into_inner().into(), - false, - true, - 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, - &rand_iban_payto().into_inner().into(), - 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: merchant_payto.into(), - exchange_payto: exchange_payto.into(), - customer_payto: customer_payto.into(), - unknown_payto: rand_iban_payto().convert(), - tmp_payto: rand_iban_payto().convert(), - admin_payto: admin_payto.into(), - server, - state, - tokens: BTreeMap::new(), - }; - ctx.cache_tokens(&["admin", "merchant", "exchange", "customer"]) - .await; - - if ctx.state.cfg.fiat.is_some() { - // Set conversion rates - ctx.post_admin("/conversion-info/conversion-rate") - .json(json!({ - "cashin_ratio" :"0.8", - "cashin_fee" :"KUDOS:0.02", - "cashin_tiny_amount" :"KUDOS:0.01", - "cashin_rounding_mode" :"nearest", - "cashin_min_amount" :"EUR:0", - "cashout_ratio" :"1.26", - "cashout_fee" :"EUR:0.003", - "cashout_tiny_amount" :"EUR:0.01", - "cashout_rounding_mode" :"zero", - "cashout_min_amount" :"KUDOS:0.1" - })) - .await - .assert_no_content(); - ctx.create_conversion_rate_class().await; - } - - ctx - } - - pub async fn swap_cfg(mut self, conf: &str) -> Self { - let cfg = Config::from_file(CONFIG_SOURCE, Some(format!("conf/{conf}"))).unwrap(); - - let state = Arc::new( - BankState::start(self.state.db.clone(), BankCfg::parse(&cfg).unwrap()).await, - ); - self.server = bank_api(state.clone()); - self.state = state; - self + fn extract_username(path: &str) -> &str { + if path.contains("admin") { + "admin" + } else { + path.split('/').nth(2).unwrap() } + } - fn pw_auth(req: TestRequest, username: Option<&str>) -> TestRequest { - let username: CompactString = username - .unwrap_or_else(|| Self::extract_username(req.url.path())) - .into(); - req.basic_auth(&username, &format!("{username}-password")) - } + pub struct BankClient { + server: Router, + tokens: BTreeMap<CompactString, String>, + pub state: Arc<BankState>, + } - fn extract_username(path: &str) -> &str { - if path.contains("admin") { - "admin" - } else { - path.split('/').nth(2).unwrap() + impl BankClient { + pub async fn new(cfg: &Config, db: PgPool) -> Self { + let state = Arc::new(BankState::start(db.clone(), BankCfg::parse(cfg).unwrap()).await); + Self { + server: bank_api(state.clone()), + tokens: Default::default(), + state, } } @@ -471,7 +339,7 @@ pub mod test { .map(|username| { let username = CompactString::from(*username); async { - let res = Self::pw_auth( + let res = pw_auth( self.server.post(format!("/accounts/{username}/token")), Some("admin"), ) @@ -501,7 +369,7 @@ pub mod test { next: Next| { let path = req.uri().path(); if path.starts_with("/accounts") && !path.contains("admin") { - let username = Self::extract_username(req.uri().path()); + let username = extract_username(req.uri().path()); let header = HeaderValue::from_str(&tokens[username]).unwrap(); req.headers_mut().insert(AUTHORIZATION, header); } else { @@ -522,7 +390,7 @@ pub mod test { username: Option<&str>, ) -> TestRequest { let path = path.as_ref(); - let username = username.unwrap_or_else(|| Self::extract_username(path)); + let username = username.unwrap_or_else(|| extract_username(path)); let token = &self.tokens[username]; self.server .request(method, path) @@ -530,7 +398,7 @@ pub mod test { } pub fn postpw(&self, path: impl AsRef<str>) -> TestRequest { - Self::pw_auth(self.server.request(Method::POST, path), None) + pw_auth(self.server.request(Method::POST, path), None) } pub fn geta(&self, path: impl AsRef<str>) -> TestRequest { @@ -617,6 +485,241 @@ pub mod test { .assert_no_content(); } + pub async fn cashout(&self, amount: impl Display) { + let amount = format!("{}:{amount}", self.state.cfg.regional_currency); + self.posta("/accounts/customer/cashouts") + .json({ + json!({ + "request_uid": ShortHashCode::rand(), + "amount_debit": amount, + "amount_credit": self.convert(&amount).await + }) + }) + .await + .assert_ok() + } + + pub async fn cashin(&self, amount: &str) { + sqlx::query("SELECT 0 FROM cashin($1, $2, $3, $4)") + .bind_timestamp(&Timestamp::now()) + .bind(ShortHashCode::rand()) + .bind(decimal(amount)) + .bind("") + .fetch_one(&self.state.db) + .await + .unwrap(); + } + + pub async fn create_conversion_rate_class(&self) -> u64 { + self.post_admin("/conversion-rate-classes") + .json(json!({ + "name": format!("Gen class {}", Timestamp::now()) + })) + .await + .assert_ok_json::<ConversionRateClassResponse>() + .conversion_rate_class_id + } + + pub async fn convert(&self, amount: impl Display) -> Amount { + self.get(format!( + "/conversion-info/cashout-rate?amount_debit={amount}" + )) + .await + .assert_ok_json::<ConversionResponse>() + .amount_credit + } + + /** Set [account] debit threshold to [maxDebt] amount */ + pub async fn set_max_debt(&self, username: &str, amount: &str) { + self.patch_admin(format!("/accounts/{username}")) + .json(json!({ + "debit_threshold": format!("{}:{amount}", self.state.cfg.regional_currency) + })) + .await + .assert_no_content(); + } + + /** Check [account] balance is [amount], [amount] is prefixed with + for credit and - for debit */ + pub async fn assert_balance(&self, username: &str, expected: &str) { + let res: AccountData = self + .get_admin(format!("/accounts/{username}")) + .await + .assert_ok_json(); + let Balance { + amount, + credit_debit_indicator, + } = res.balance; + let prefix = match credit_debit_indicator { + CreditDebitInfo::credit => "", + CreditDebitInfo::debit => "-", + }; + pretty_assertions::assert_eq!(format!("{prefix}{}", amount.decimal()), expected); + } + } + + impl Deref for BankClient { + type Target = Router; + + fn deref(&self) -> &Self::Target { + &self.server + } + } + + pub struct BankTestCtx { + pub merchant_payto: BankPayto, + pub exchange_payto: BankPayto, + pub customer_payto: BankPayto, + pub unknown_payto: BankPayto, + pub tmp_payto: BankPayto, + pub admin_payto: BankPayto, + pub client: BankClient, + } + + impl BankTestCtx { + pub async fn new(conf: &str) -> Self { + let cfg = Config::load(CONFIG_SOURCE, Some(format!("conf/{conf}"))).unwrap(); + let cfg = BankCfg::parse(&cfg).unwrap(); + let dir = cfg.db_cfg.sql_dir.as_ref(); + let (mut conn, db) = db_test_setup_manual(dir, "libeufin-bank").await; + dbinit(&mut conn, dir, "libeufin-bank", true).await.unwrap(); + dbinit(&mut conn, dir, "libeufin-nexus", true) + .await + .unwrap(); + let procedure = + std::fs::read_to_string(dir.join("libeufin-conversion-setup.sql")).unwrap(); + sqlx::raw_sql(&procedure).execute(&mut *conn).await.unwrap(); + drop(conn); + + let state = Arc::new(BankState::start(db.clone(), cfg).await); + let server = bank_api(state.clone()); + + let merchant_payto = db::account::create( + &db, + &state.cfg.ctx, + &state.cfg.pw_crypto, + "merchant", + "merchant-password", + "Merchant", + None, + None, + None, + &rand_iban_payto().into_inner().into(), + 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, + &rand_iban_payto().into_inner().into(), + false, + true, + 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, + &rand_iban_payto().into_inner().into(), + 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: merchant_payto.into(), + exchange_payto: exchange_payto.into(), + customer_payto: customer_payto.into(), + unknown_payto: rand_iban_payto().convert(), + tmp_payto: rand_iban_payto().convert(), + admin_payto: admin_payto.into(), + client: BankClient { + server, + tokens: BTreeMap::new(), + state, + }, + }; + ctx.client + .cache_tokens(&["admin", "merchant", "exchange", "customer"]) + .await; + + if ctx.state.cfg.fiat.is_some() { + // Set conversion rates + ctx.post_admin("/conversion-info/conversion-rate") + .json(json!({ + "cashin_ratio" :"0.8", + "cashin_fee" :"KUDOS:0.02", + "cashin_tiny_amount" :"KUDOS:0.01", + "cashin_rounding_mode" :"nearest", + "cashin_min_amount" :"EUR:0", + "cashout_ratio" :"1.26", + "cashout_fee" :"EUR:0.003", + "cashout_tiny_amount" :"EUR:0.01", + "cashout_rounding_mode" :"zero", + "cashout_min_amount" :"KUDOS:0.1" + })) + .await + .assert_no_content(); + ctx.create_conversion_rate_class().await; + } + + ctx + } + + pub async fn swap_cfg(mut self, conf: &str) -> Self { + let cfg = Config::load(CONFIG_SOURCE, Some(format!("conf/{conf}"))).unwrap(); + + let state = Arc::new( + BankState::start(self.state.db.clone(), BankCfg::parse(&cfg).unwrap()).await, + ); + self.client.server = bank_api(state.clone()); + self.state = state; + self + } + pub async fn fill_cashout_info(&self, username: &str) { self.patch_admin(format!("/accounts/{username}")) .json(json!({ @@ -702,31 +805,6 @@ pub mod test { .assert_ok() } - pub async fn cashout(&self, amount: &str) { - let amount = format!("{}:{amount}", self.state.cfg.regional_currency); - self.posta("/accounts/customer/cashouts") - .json({ - json!({ - "request_uid": ShortHashCode::rand(), - "amount_debit": amount, - "amount_credit": self.convert(&amount).await - }) - }) - .await - .assert_ok() - } - - pub async fn cashin(&self, amount: &str) { - sqlx::query("SELECT 0 FROM cashin($1, $2, $3, $4)") - .bind_timestamp(&Timestamp::now()) - .bind(ShortHashCode::rand()) - .bind(decimal(amount)) - .bind("") - .fetch_one(&self.state.db) - .await - .unwrap(); - } - pub async fn withdrawal(&self, amount: &str) { let uuid = self .posta("/accounts/merchant/withdrawals") @@ -756,59 +834,19 @@ pub mod test { .assert_ok(); key } - - pub async fn create_conversion_rate_class(&self) -> u64 { - self.post_admin("/conversion-rate-classes") - .json(json!({ - "name": format!("Gen class {}", Timestamp::now()) - })) - .await - .assert_ok_json::<ConversionRateClassResponse>() - .conversion_rate_class_id - } - - pub async fn convert(&self, amount: &str) -> Amount { - self.get(format!( - "/conversion-info/cashout-rate?amount_debit={amount}" - )) - .await - .assert_ok_json::<ConversionResponse>() - .amount_credit - } - - /** Set [account] debit threshold to [maxDebt] amount */ - pub async fn set_max_debt(&self, username: &str, amount: &str) { - self.patch_admin(format!("/accounts/{username}")) - .json(json!({ - "debit_threshold": format!("{}:{amount}", self.state.cfg.regional_currency) - })) - .await - .assert_no_content(); - } - - /** Check [account] balance is [amount], [amount] is prefixed with + for credit and - for debit */ - pub async fn assert_balance(&self, username: &str, expected: &str) { - let res: AccountData = self - .get_admin(format!("/accounts/{username}")) - .await - .assert_ok_json(); - let Balance { - amount, - credit_debit_indicator, - } = res.balance; - let prefix = match credit_debit_indicator { - CreditDebitInfo::credit => "", - CreditDebitInfo::debit => "-", - }; - pretty_assertions::assert_eq!(format!("{prefix}{}", amount.decimal()), expected); - } } impl Deref for BankTestCtx { - type Target = Router; + type Target = BankClient; fn deref(&self) -> &Self::Target { - &self.server + &self.client + } + } + + impl DerefMut for BankTestCtx { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.client } } diff --git a/libeufin-bank/src/bench.rs b/libeufin-bank/src/bench.rs @@ -216,7 +216,8 @@ pub async fn bench_db() { names.push(username); }) .await; - ctx.cache_tokens(&names.iter().map(|it| it.as_str()).collect::<Vec<_>>()) + ctx.client + .cache_tokens(&names.iter().map(|it| it.as_str()).collect::<Vec<_>>()) .await; b.measure("account_reconfig", async |i| { ctx.patcha(format!("/accounts/account_bench_{i}")) diff --git a/libeufin-bank/src/db.rs b/libeufin-bank/src/db.rs @@ -21,11 +21,13 @@ use std::time::Duration; +use anyhow::bail; use jiff::Timestamp; -use sqlx::{PgPool, postgres::PgRow}; +use sqlx::{PgConnection, PgPool, postgres::PgRow}; use taler_api::{config::DbCfg, db::TypeHelper, notification::NotificationChannel, serialized}; use taler_common::types::amount::{Amount, Currency}; use tokio::join; +use tracing::info; use uuid::Uuid; use crate::api::{MonitorParams, MonitorResponse, withdrawal::WithdrawalStatus}; @@ -48,13 +50,58 @@ pub async fn pool(cfg: &DbCfg) -> anyhow::Result<PgPool> { Ok(pool) } -pub async fn dbinit(cfg: &DbCfg, reset: bool) -> anyhow::Result<PgPool> { +pub async fn dbinit(cfg: &DbCfg, reset: bool, conversion: bool) -> anyhow::Result<PgPool> { let pool = taler_common::db::pool(cfg.cfg.clone(), SCHEMA).await?; let mut db = pool.acquire().await?; - taler_common::db::dbinit(&mut db, cfg.sql_dir.as_ref(), "libeufin-bank", reset).await?; + // Keep conversion setup in sync in dbinit as procedure installation clears it + taler_common::db::dbinit_setup( + &mut db, + cfg.sql_dir.as_ref(), + "libeufin-bank", + reset, + async |tx| setup_conversion(cfg, tx.as_mut(), conversion).await, + ) + .await?; Ok(pool) } +pub async fn setup_conversion( + cfg: &DbCfg, + db: &mut PgConnection, + enabled: bool, +) -> anyhow::Result<()> { + if enabled { + info!("Ensure conversion is enabled"); + match std::fs::read_to_string(format!("{}/libeufin-conversion-setup.sql", cfg.sql_dir)) { + Ok(sql) => { + sqlx::raw_sql(&sql).execute(&mut *db).await?; + } + Err(e) => { + bail!( + "Could not read libeufin-conversion-setup.sql at '{}': {}", + cfg.sql_dir, + e.kind() + ) + } + }; + } else { + info!("Ensure conversion is disabled"); + match std::fs::read_to_string(format!("{}/libeufin-conversion-drop.sql", cfg.sql_dir)) { + Ok(sql) => { + sqlx::raw_sql(&sql).execute(&mut *db).await?; + } + Err(e) => { + bail!( + "Could not read libeufin-conversion-setup.sql at '{}': {}", + cfg.sql_dir, + e.kind() + ) + } + }; + } + Ok(()) +} + pub async fn notification_listener( pool: PgPool, tx_channel: NotificationChannel<u64, i64>, diff --git a/libeufin-bank/src/lib.rs b/libeufin-bank/src/lib.rs @@ -61,7 +61,7 @@ use crate::{ }, dbinit, gc::collect, - pool, + pool, setup_conversion, token::{TokenCreationResult, access}, }, payto::{BankPayto, PaytoCtx}, @@ -257,7 +257,7 @@ pub async fn run(cfg: &Config, cmd: Cmd) -> anyhow::Result<()> { match cmd { Cmd::Dbinit { reset } => { let cfg = BankCfg::parse(cfg)?; - let db = dbinit(&cfg.db_cfg, reset).await?; + let db = dbinit(&cfg.db_cfg, reset, cfg.fiat.is_some()).await?; match create_admin_account(&db, &cfg, None).await? { CreationResult::Success(_) => { info!("Admin's account created") @@ -342,6 +342,7 @@ pub async fn run(cfg: &Config, cmd: Cmd) -> anyhow::Result<()> { let cfg = BankCfg::parse(cfg)?; let db = pool(&cfg.db_cfg).await?; if cfg.fiat.is_some() { + setup_conversion(&cfg.db_cfg, db.acquire().await?.as_mut(), true).await?; info!("Ensure exchange account exists"); let Some(info) = bank_info(&db, &cfg.ctx, "exchange").await? else { bail!( @@ -353,39 +354,8 @@ pub async fn run(cfg: &Config, cmd: Cmd) -> anyhow::Result<()> { "Account is not an exchange: an exchange account named 'exchange' is required for conversion to be enabled" ) } - info!("Ensure conversion is enabled"); - match std::fs::read_to_string(format!( - "{}/libeufin-conversion-setup.sql", - cfg.db_cfg.sql_dir - )) { - Ok(sql) => { - sqlx::raw_sql(&sql).execute(&db).await?; - } - Err(e) => { - bail!( - "Could not read libeufin-conversion-setup.sql at '{}': {}", - cfg.db_cfg.sql_dir, - e.kind() - ) - } - }; } else { - info!("Ensure conversion is disabled"); - match std::fs::read_to_string(format!( - "{}/libeufin-conversion-drop.sql", - cfg.db_cfg.sql_dir - )) { - Ok(sql) => { - sqlx::raw_sql(&sql).execute(&db).await?; - } - Err(e) => { - bail!( - "Could not read libeufin-conversion-setup.sql at '{}': {}", - cfg.db_cfg.sql_dir, - e.kind() - ) - } - }; + setup_conversion(&cfg.db_cfg, db.acquire().await?.as_mut(), false).await?; } let state = Arc::new(BankState::start(db, cfg).await); bank_api(state.clone()) diff --git a/libeufin-common/src/main/kotlin/TalerCommon.kt b/libeufin-common/src/main/kotlin/TalerCommon.kt @@ -1,781 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. - * - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - * - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - * - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.common - -import io.ktor.http.* -import io.ktor.server.plugins.* -import kotlinx.serialization.* -import kotlinx.serialization.descriptors.* -import kotlinx.serialization.encoding.* -import kotlinx.serialization.json.* -import java.time.Instant -import java.time.Duration -import java.time.temporal.ChronoUnit -import java.util.concurrent.TimeUnit -import java.nio.ByteBuffer -import java.nio.ByteOrder -import org.bouncycastle.math.ec.rfc8032.Ed25519 -import io.github.smiley4.schemakenerator.core.annotations.Description -import kotlinx.io.bytestring.putByteString - -sealed class CommonError(msg: String) : Exception(msg) { - class AmountFormat(msg: String) : CommonError(msg) - class AmountNumberTooBig(msg: String) : CommonError(msg) - class Payto(msg: String) : CommonError(msg) -} - -/** - * Internal representation of relative times. The - * "forever" case is represented with Long.MAX_VALUE. - */ -@Description("Relative time duration, serialized as microseconds or 'forever'") -@JvmInline -@Serializable(with = RelativeTime.Serializer::class) -value class RelativeTime(val duration: Duration) { - internal object Serializer : KSerializer<RelativeTime> { - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("RelativeTime") { - element<JsonElement>("d_us") - } - - override fun serialize(encoder: Encoder, value: RelativeTime) { - val composite = encoder.beginStructure(descriptor) - if (value.duration == ChronoUnit.FOREVER.duration) { - composite.encodeStringElement(descriptor, 0, "forever") - } else { - composite.encodeLongElement(descriptor, 0, TimeUnit.MICROSECONDS.convert(value.duration)) - } - composite.endStructure(descriptor) - } - - override fun deserialize(decoder: Decoder): RelativeTime { - val dec = decoder.beginStructure(descriptor) - val jsonInput = dec as? JsonDecoder ?: error("Can be deserialized only by JSON") - lateinit var maybeDUs: JsonPrimitive - loop@ while (true) { - when (val index = dec.decodeElementIndex(descriptor)) { - 0 -> maybeDUs = jsonInput.decodeJsonElement().jsonPrimitive - CompositeDecoder.DECODE_DONE -> break@loop - else -> throw SerializationException("Unexpected index: $index") - } - } - dec.endStructure(descriptor) - if (maybeDUs.isString) { - if (maybeDUs.content != "forever") throw badRequest("Only 'forever' allowed for d_us as string, but '${maybeDUs.content}' was found") - return RelativeTime(ChronoUnit.FOREVER.duration) - } - val dUs: Long = maybeDUs.longOrNull - ?: throw badRequest("Could not convert d_us: '${maybeDUs.content}' to a number") - when { - dUs < 0 -> throw badRequest("Negative duration specified.") - dUs > MAX_SAFE_INTEGER -> throw badRequest("d_us value $dUs exceed cap (2^53-1)") - else -> return RelativeTime(Duration.of(dUs, ChronoUnit.MICROS)) - } - } - } - - companion object { - const val MAX_SAFE_INTEGER = 9007199254740991L // 2^53 - 1 - } -} - -/** Timestamp containing the number of seconds since epoch */ -@Description("Timestamp as seconds since Unix epoch, or 'never'") -@JvmInline -@Serializable(with = TalerTimestamp.Serializer::class) -value class TalerTimestamp constructor(val instant: Instant) { - internal object Serializer : KSerializer<TalerTimestamp> { - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("Timestamp") { - element<JsonElement>("t_s") - } - - override fun serialize(encoder: Encoder, value: TalerTimestamp) { - val composite = encoder.beginStructure(descriptor) - if (value.instant == Instant.MAX) { - composite.encodeStringElement(descriptor, 0, "never") - } else { - composite.encodeLongElement(descriptor, 0, value.instant.epochSecond) - } - composite.endStructure(descriptor) - } - - override fun deserialize(decoder: Decoder): TalerTimestamp { - val dec = decoder.beginStructure(descriptor) - val jsonInput = dec as? JsonDecoder ?: error("Can be deserialized only by JSON") - lateinit var maybeTs: JsonPrimitive - loop@ while (true) { - when (val index = dec.decodeElementIndex(descriptor)) { - 0 -> maybeTs = jsonInput.decodeJsonElement().jsonPrimitive - CompositeDecoder.DECODE_DONE -> break@loop - else -> throw SerializationException("Unexpected index: $index") - } - } - dec.endStructure(descriptor) - if (maybeTs.isString) { - if (maybeTs.content != "never") throw badRequest("Only 'never' allowed for t_s as string, but '${maybeTs.content}' was found") - return TalerTimestamp(Instant.MAX) - } - val ts: Long = maybeTs.longOrNull - ?: throw badRequest("Could not convert t_s '${maybeTs.content}' to a number") - when { - ts < 0 -> throw badRequest("Negative timestamp not allowed") - ts > Instant.MAX.epochSecond -> throw badRequest("Timestamp $ts too big to be represented in Kotlin") - else -> return TalerTimestamp(Instant.ofEpochSecond(ts)) - } - } - } - - companion object { - fun never(): TalerTimestamp = TalerTimestamp(Instant.MAX) - } -} - -@Description("Base URL string ending with a trailing slash") -@JvmInline -@Serializable(with = BaseURL.Serializer::class) -value class BaseURL private constructor(val url: Url) { - companion object { - fun parse(raw: String): BaseURL { - val url = URLBuilder(raw) - if (url.protocolOrNull == null) { - throw badRequest("missing protocol in baseURL got '${url}'") - } else if (url.protocol.name !in setOf("http", "https")) { - throw badRequest("only 'http' and 'https' are accepted for baseURL got '${url.protocol.name}'") - } else if (url.host.isEmpty()) { - throw badRequest("missing host in baseURL got '${url}'") - } else if (!url.parameters.isEmpty()) { - throw badRequest("require no query in baseURL got '${url.encodedParameters}'") - } else if (url.fragment.isNotEmpty()) { - throw badRequest("require no fragments in baseURL got '${url.fragment}'") - } else if (!url.encodedPath.endsWith('/')) { - throw badRequest("baseURL path must end with / got '${url.encodedPath}'") - } - return BaseURL(url.build()) - } - } - - override fun toString(): String = url.toString() - - internal object Serializer : KSerializer<BaseURL> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("BaseURL", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: BaseURL) { - encoder.encodeString(value.url.toString()) - } - - override fun deserialize(decoder: Decoder): BaseURL { - return BaseURL.parse(decoder.decodeString()) - } - } -} - -@Serializable(with = DecimalNumber.Serializer::class) -class DecimalNumber { - val value: Long - val frac: Int - - constructor(value: Long, frac: Int) { - this.value = value - this.frac = frac - } - - constructor(encoded: String) { - val match = PATTERN.matchEntire(encoded) ?: throw badRequest("Invalid decimal number format") - val (value, frac) = match.destructured - this.value = value.toLongOrNull() ?: throw badRequest("Invalid value") - if (this.value > TalerAmount.MAX_VALUE) - throw badRequest("Value specified in decimal number is too large") - this.frac = if (frac.isEmpty()) { - 0 - } else { - var tmp = frac.toIntOrNull() ?: throw badRequest("Invalid fractional value") - if (tmp > TalerAmount.FRACTION_BASE) - throw badRequest("Fractional value specified in decimal number is too large") - repeat(8 - frac.length) { - tmp *= 10 - } - tmp - } - } - - fun isZero(): Boolean = value == 0L && frac == 0 - - override fun equals(other: Any?): Boolean { - return other is DecimalNumber && - other.value == this.value && - other.frac == this.frac - } - - override fun toString(): String { - return if (frac == 0) { - "$value" - } else { - "$value.${frac.toString().padStart(8, '0')}" - .dropLastWhile { it == '0' } // Trim useless fractional trailing 0 - } - } - - internal object Serializer : KSerializer<DecimalNumber> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("DecimalNumber", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: DecimalNumber) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): DecimalNumber { - return DecimalNumber(decoder.decodeString()) - } - } - - companion object { - val ZERO = DecimalNumber(0, 0) - private val PATTERN = Regex("([0-9]+)(?:\\.([0-9]{1,8}))?") - } -} - -@Serializable(with = TalerAmount.Serializer::class) -class TalerAmount : Comparable<TalerAmount> { - val value: Long - val frac: Int - val currency: String - - constructor(value: Long, frac: Int, currency: String) { - this.value = value - this.frac = frac - this.currency = currency - } - - constructor(encoded: String) { - val match = PATTERN.matchEntire(encoded) ?: throw CommonError.AmountFormat("Invalid amount format") - val (currency, value, frac) = match.destructured - this.currency = currency - this.value = value.toLongOrNull() ?: throw CommonError.AmountFormat("Invalid value") - if (this.value > MAX_VALUE) - throw CommonError.AmountNumberTooBig("Value specified in amount is too large") - this.frac = if (frac.isEmpty()) { - 0 - } else { - var tmp = frac.toIntOrNull() ?: throw CommonError.AmountFormat("Invalid fractional value") - if (tmp > FRACTION_BASE) - throw CommonError.AmountFormat("Fractional value specified in amount is too large") - repeat(8 - frac.length) { - tmp *= 10 - } - - tmp - } - } - - fun number(): DecimalNumber = DecimalNumber(value, frac) - - /* Check if zero */ - fun isZero(): Boolean = value == 0L && frac == 0 - - fun notZeroOrNull(): TalerAmount? = if (isZero()) null else this - - /* Check is amount has fractional amount < 0.01 */ - fun isSubCent(): Boolean = (frac % CENT_FRACTION) > 0 - - /* Network bytes */ - fun nbo(): ByteArray = ByteBuffer.allocate(24).apply { - order(ByteOrder.BIG_ENDIAN) - putLong(value) - putInt(frac) - val curr = currency.encodeToByteArray() - put(curr) - repeat(12 - curr.size) { - put(0) - } - }.array() - - override fun equals(other: Any?): Boolean { - return other is TalerAmount && - other.value == this.value && - other.frac == this.frac && - other.currency == this.currency - } - - override fun toString(): String { - return if (frac == 0) { - "$currency:$value" - } else { - "$currency:$value.${frac.toString().padStart(8, '0')}" - .dropLastWhile { it == '0' } // Trim useless fractional trailing 0 - } - } - - fun normalize(): TalerAmount { - val value = Math.addExact(this.value, (this.frac / FRACTION_BASE).toLong()) - val frac = this.frac % FRACTION_BASE - if (value > MAX_VALUE) throw ArithmeticException("amount value overflowed") - return TalerAmount(value, frac, currency) - } - - override operator fun compareTo(other: TalerAmount) = compareValuesBy(this, other, { it.value }, { it.frac }) - - operator fun plus(increment: TalerAmount): TalerAmount { - require(this.currency == increment.currency) { "currency mismatch ${this.currency} != ${increment.currency}" } - val value = Math.addExact(this.value, increment.value) - val frac = Math.addExact(this.frac, increment.frac) - return TalerAmount(value, frac, currency).normalize() - } - - operator fun minus(decrement: TalerAmount): TalerAmount { - require(this.currency == decrement.currency) { "currency mismatch ${this.currency} != ${decrement.currency}" } - var frac = this.frac - var value = this.value - if (frac < decrement.frac) { - if (value <= 0) { - throw ArithmeticException("negative result") - } - frac += FRACTION_BASE - value -= 1 - } - if (value < decrement.value) { - throw ArithmeticException("negative result") - } - return TalerAmount(value - decrement.value, frac - decrement.frac, currency).normalize() - } - - internal object Serializer : KSerializer<TalerAmount> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("TalerAmount", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: TalerAmount) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): TalerAmount = - TalerAmount(decoder.decodeString()) - - } - - companion object { - const val FRACTION_BASE = 100000000 - const val CENT_FRACTION = 1000000 - const val MAX_VALUE = 4503599627370496L // 2^52 - private val PATTERN = Regex("([A-Z]{1,11}):([0-9]+)(?:\\.([0-9]{1,8}))?") - - fun zero(currency: String) = TalerAmount(0, 0, currency) - fun max(currency: String) = TalerAmount(MAX_VALUE, FRACTION_BASE - 1, currency) - } -} - -@Serializable(with = Payto.Serializer::class) -sealed class Payto { - abstract val parsed: Url - abstract val canonical: String - abstract val amount: TalerAmount? - abstract val message: String? - abstract val receiverName: String? - - /** Transform a payto URI to its bank form, using [name] as the receiver-name and the bank [ctx] */ - fun bank(name: String?, ctx: BankPaytoCtx): String = when (this) { - is IbanPayto -> IbanPayto.build(iban.toString(), ctx.bic, name) - is XTalerBankPayto -> { - val name = if (name != null) "?receiver-name=${name.encodeURLParameter()}" else "" - "payto://x-taler-bank/${ctx.hostname}/$username$name" - } - } - - fun expectIbanFull(): IbanPayto { - val payto = expectIban() - if (payto.receiverName == null) { - throw CommonError.Payto("expected a full IBAN payto got no receiver-name") - } - return payto - } - - fun expectIban(): IbanPayto { - return when (this) { - is IbanPayto -> this - else -> throw CommonError.Payto("expected an IBAN payto URI got '${parsed.host}'") - } - } - - fun expectXTalerBank(): XTalerBankPayto { - return when (this) { - is XTalerBankPayto -> this - else -> throw CommonError.Payto("expected a x-taler-bank payto URI got '${parsed.host}'") - } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is Payto) return false - return this.parsed == other.parsed - } - - internal object Serializer : KSerializer<Payto> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("Payto", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: Payto) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): Payto { - return parse(decoder.decodeString()) - } - } - - companion object { - private val HEX_PATTERN: Regex = Regex("%(?![0-9a-fA-F]{2})") - fun parse(input: String): Payto { - val raw = input.replace(HEX_PATTERN, "%25") - val parsed = try { - Url(raw) - } catch (e: Exception) { - throw CommonError.Payto("expected a valid URI") - } - if (parsed.protocol.name != "payto") throw CommonError.Payto("expect a payto URI got '${parsed.protocol.name}'") - - val amount = parsed.parameters["amount"]?.run { TalerAmount(this) } - val message = parsed.parameters["message"] - val receiverName = parsed.parameters["receiver-name"] - - return when (parsed.host) { - "iban" -> { - val segments = parsed.segments - val (bic, rawIban) = when (segments.size) { - 1 -> Pair(null, segments[0]) - 2 -> Pair(segments[0], segments[1]) - else -> throw CommonError.Payto("too many path segments for an IBAN payto URI") - } - val iban = IBAN.parse(rawIban) - IbanPayto( - parsed, - "payto://iban/$iban", - amount, - message, - receiverName, - parsed.parameters["ch-qrr"], - bic, - iban, - ) - } - - "x-taler-bank" -> { - val segments = parsed.segments - if (segments.size != 2) - throw CommonError.Payto("bad number of path segments for a x-taler-bank payto URI") - val username = segments[1] - XTalerBankPayto( - parsed, - "payto://x-taler-bank/localhost/$username", - amount, - message, - receiverName, - username - ) - } - - else -> throw CommonError.Payto("unsupported payto URI kind '${parsed.host}'") - } - } - } -} - -@Serializable(with = IbanPayto.Serializer::class) -class IbanPayto internal constructor( - override val parsed: Url, - override val canonical: String, - override val amount: TalerAmount?, - override val message: String?, - override val receiverName: String?, - val chQrr: String?, - val bic: String?, - val iban: IBAN -) : Payto() { - override fun toString(): String = parsed.toString() - - /** Format an IbanPayto in a more human readable way */ - fun fmt(): String = buildString { - append('(') - append(iban) - if (bic != null) { - append(' ') - append(bic) - } - if (receiverName != null) { - append(' ') - append(receiverName) - } - append(')') - } - - /** Transform an IBAN payto URI to its simple form without any query */ - fun simple(): String = build(iban.toString(), bic, null) - - /** Transform an IBAN payto URI to its full form, using [name] as its receiver-name */ - fun full(name: String): String = build(iban.toString(), bic, name) - - internal object Serializer : KSerializer<IbanPayto> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("IbanPayto", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: IbanPayto) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): IbanPayto { - return parse(decoder.decodeString()).expectIban() - } - } - - companion object { - fun build(iban: String, bic: String?, name: String?): String { - val bic = if (bic != null) "$bic/" else "" - val name = if (name != null) "?receiver-name=${name.encodeURLParameter()}" else "" - return "payto://iban/$bic$iban$name" - } - - fun rand(name: String? = null, country: Country = Country.DE): IbanPayto = parse( - "payto://iban/${IBAN.rand(country)}${ - if (name != null) { - "?receiver-name=${name.encodeURLParameter()}" - } else { - "" - } - }" - ).expectIban() - } -} - -class XTalerBankPayto internal constructor( - override val parsed: Url, - override val canonical: String, - override val amount: TalerAmount?, - override val message: String?, - override val receiverName: String?, - val username: String -) : Payto() { - override fun toString(): String = parsed.toString() - - companion object { - fun forUsername(username: String): XTalerBankPayto { - return parse("payto://x-taler-bank/hostname/$username").expectXTalerBank() - } - } -} - -/** Context specific data necessary to create a bank payto URI from a canonical payto URI */ -data class BankPaytoCtx( - val bic: String?, - val hostname: String -) - - -/** 16-byte Crockford's Base32 encoded data */ -@Serializable(with = Base32Crockford16B.Serializer::class) -class Base32Crockford16B { - private var encoded: String? = null - val raw: ByteArray - - constructor(encoded: String) { - val decoded = try { - Base32Crockford.decode(encoded) - } catch (e: IllegalArgumentException) { - null - } - require(decoded != null && decoded.size == 16) { - "expected 16 bytes encoded in Crockford's base32" - } - this.raw = decoded - this.encoded = encoded - } - - constructor(raw: ByteArray) { - require(raw.size == 16) { - "encoded data should be 16 bytes long" - } - this.raw = raw - } - - fun encoded(): String { - val tmp = encoded ?: Base32Crockford.encode(raw) - encoded = tmp - return tmp - } - - override fun toString(): String { - return encoded() - } - - override fun equals(other: Any?) = (other is Base32Crockford16B) && raw.contentEquals(other.raw) - - internal object Serializer : KSerializer<Base32Crockford16B> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("Base32Crockford16B", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: Base32Crockford16B) { - encoder.encodeString(value.encoded()) - } - - override fun deserialize(decoder: Decoder): Base32Crockford16B { - return Base32Crockford16B(decoder.decodeString()) - } - } - - companion object { - fun rand(): Base32Crockford16B = Base32Crockford16B(ByteArray(16).rand()) - fun secureRand(): Base32Crockford16B = Base32Crockford16B(ByteArray(16).secureRand()) - } -} - -/** 32-byte Crockford's Base32 encoded data */ -@Description("32-byte Crockford Base32 encoded data") -@Serializable(with = Base32Crockford32B.Serializer::class) -class Base32Crockford32B { - private var encoded: String? = null - val raw: ByteArray - - constructor(encoded: String) { - val decoded = try { - Base32Crockford.decode(encoded) - } catch (e: IllegalArgumentException) { - null - } - require(decoded != null && decoded.size == 32) { - "expected 32 bytes encoded in Crockford's base32" - } - this.raw = decoded - this.encoded = encoded - } - - constructor(raw: ByteArray) { - require(raw.size == 32) { - "encoded data should be 32 bytes long" - } - this.raw = raw - } - - fun encoded(): String { - val tmp = encoded ?: Base32Crockford.encode(raw) - encoded = tmp - return tmp - } - - override fun toString(): String { - return encoded() - } - - override fun equals(other: Any?) = (other is Base32Crockford32B) && raw.contentEquals(other.raw) - - internal object Serializer : KSerializer<Base32Crockford32B> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("Base32Crockford32B", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: Base32Crockford32B) { - encoder.encodeString(value.encoded()) - } - - override fun deserialize(decoder: Decoder): Base32Crockford32B { - return Base32Crockford32B(decoder.decodeString()) - } - } - - companion object { - fun rand(): Base32Crockford32B = Base32Crockford32B(ByteArray(32).rand()) - fun secureRand(): Base32Crockford32B = Base32Crockford32B(ByteArray(32).secureRand()) - fun randEdsaKey(): EddsaPublicKey = randEdsaKeyPair().second - fun randEdsaKeyPair(): Pair<ByteArray, EddsaPublicKey> { - val secretKey = ByteArray(32) - Ed25519.generatePrivateKey(SECURE_RNG.get(), secretKey) - val publicKey = ByteArray(32) - Ed25519.generatePublicKey(secretKey, 0, publicKey, 0) - return Pair(secretKey, Base32Crockford32B(publicKey)) - } - } -} - -/** 64-byte Crockford's Base32 encoded data */ -@Description("64-byte Crockford Base32 encoded data") -@Serializable(with = Base32Crockford64B.Serializer::class) -class Base32Crockford64B { - private var encoded: String? = null - val raw: ByteArray - - constructor(encoded: String) { - val decoded = try { - Base32Crockford.decode(encoded) - } catch (e: IllegalArgumentException) { - null - } - - require(decoded != null && decoded.size == 64) { - "expected 64 bytes encoded in Crockford's base32" - } - this.raw = decoded - this.encoded = encoded - } - - constructor(raw: ByteArray) { - require(raw.size == 64) { - "encoded data should be 64 bytes long" - } - this.raw = raw - } - - fun encoded(): String { - val tmp = encoded ?: Base32Crockford.encode(raw) - encoded = tmp - return tmp - } - - override fun toString(): String { - return encoded() - } - - override fun equals(other: Any?) = (other is Base32Crockford64B) && raw.contentEquals(other.raw) - - internal object Serializer : KSerializer<Base32Crockford64B> { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("Base32Crockford64B", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: Base32Crockford64B) { - encoder.encodeString(value.encoded()) - } - - override fun deserialize(decoder: Decoder): Base32Crockford64B { - return Base32Crockford64B(decoder.decodeString()) - } - } - - companion object { - fun rand(): Base32Crockford64B = Base32Crockford64B(ByteArray(64).rand()) - } -} - -/** 32-byte hash code */ -typealias ShortHashCode = Base32Crockford32B -/** 64-byte hash code */ -typealias HashCode = Base32Crockford64B - -typealias EddsaSignature = Base32Crockford64B -/** - * EdDSA and ECDHE public keys always point on Curve25519 - * and represented using the standard 256 bits Ed25519 compact format, - * converted to Crockford Base32. - */ -typealias EddsaPublicKey = Base32Crockford32B diff --git a/libeufin-ebics/Cargo.toml b/libeufin-ebics/Cargo.toml @@ -29,16 +29,13 @@ uuid.workspace = true rand.workspace = true pretty_assertions.workspace = true dialoguer.workspace = true -tempfile = "3" +tempfile.workspace = true flate2 = { version = "1.0", features = ["zlib-rs"], default-features = false } zip.workspace = true futures-util = "0.3" reqwest-websocket = "0.6.0" roxmltree = "0.21.1" -rcgen = { version = "0.14.7", features = [ - "aws_lc_rs", - "pem", -], default-features = false } +rcgen = { version = "0.14.7", features = ["aws_lc_rs", "pem"], default-features = false } x509-parser = { version = "0.18.1", features = ["verify-aws"] } calamine = { version = "0.35.0" } -printpdf = { version = "0.7", default-features = false } +printpdf = { version = "0.7", default-features = false } diff --git a/libeufin-ebisync/Cargo.toml b/libeufin-ebisync/Cargo.toml @@ -17,20 +17,17 @@ sqlx.workspace = true thiserror.workspace = true tracing.workspace = true serde.workspace = true -serde_json.workspace = true taler-common.workspace = true taler-api.workspace = true taler-build.workspace = true taler-macros.workspace = true taler-test-utils.workspace = true zip.workspace = true -axum = {workspace = true, features = ["multipart"]} +axum = { workspace = true, features = ["multipart"] } anyhow.workspace = true tokio.workspace = true jiff.workspace = true clap.workspace = true -uuid.workspace = true -rand.workspace = true pretty_assertions.workspace = true dialoguer.workspace = true http-client.workspace = true diff --git a/libeufin-nexus/Cargo.toml b/libeufin-nexus/Cargo.toml @@ -21,7 +21,6 @@ taler-macros.workspace = true taler-test-utils.workspace = true clap.workspace = true aws-lc-rs.workspace = true -serde.workspace = true sqlx.workspace = true compact_str.workspace = true uuid.workspace = true @@ -29,4 +28,4 @@ shlex.workspace = true url = "2.5" regex = "1.12" const_format = { version = "0.2", features = ["rust_1_83"] } -zip.workspace = true -\ No newline at end of file +zip.workspace = true diff --git a/libeufin-nexus/src/db.rs b/libeufin-nexus/src/db.rs @@ -74,22 +74,15 @@ pub mod test { } pub async fn check_count(db: &PgPool, nb_tx: usize, nb_bounce: usize) { - sqlx::query( - " - SELECT (SELECT count(*) FROM incoming_transactions) + (SELECT count(*) FROM outgoing_transactions) AS transactions, - (SELECT count(*) FROM bounced_transactions) AS bounce - ", - ) - .try_map(|r: PgRow| { - assert_eq!( - (r.try_get_u64(0)?, r.try_get_u64(1)?), - (nb_tx as u64, nb_bounce as u64) - ); - Ok(()) - }) - .fetch_one(db) - .await - .unwrap(); + assert_eq!( + sqlx::query_as::<_, (i64, i64)>( + "SELECT (SELECT count(*) FROM incoming_transactions) + (SELECT count(*) FROM outgoing_transactions), (SELECT count(*) FROM bounced_transactions)", + ) + .fetch_one(db) + .await + .unwrap(), + (nb_tx as i64, nb_bounce as i64) + ); } pub async fn check_in_count( @@ -98,42 +91,34 @@ pub mod test { nb_bounce: usize, nb_talerable: usize, ) { - sqlx::query( - " - SELECT (SELECT count(*) FROM incoming_transactions) AS incoming, - (SELECT count(*) FROM bounced_transactions) AS bounce, - (SELECT count(*) FROM talerable_incoming_transactions) AS talerable - ", - ) - .try_map(|r: PgRow| { - assert_eq!( - (r.try_get_u64(0)?, r.try_get_u64(1)?, r.try_get_u64(2)?), - (nb_incoming as u64, nb_bounce as u64, nb_talerable as u64) - ); - Ok(()) - }) - .fetch_one(db) - .await - .unwrap(); + assert_eq!( + sqlx::query_as::<_, (i64, i64, i64)>( + " + SELECT (SELECT count(*) FROM incoming_transactions), + (SELECT count(*) FROM bounced_transactions), + (SELECT count(*) FROM talerable_incoming_transactions) + ", + ) + .fetch_one(db) + .await + .unwrap(), + (nb_incoming as i64, nb_bounce as i64, nb_talerable as i64) + ); } pub async fn check_out_count(db: &PgPool, nb_outgoing: u64, nb_talerable: u64) { - sqlx::query( - " - SELECT (SELECT count(*) FROM outgoing_transactions) AS outgoing, - (SELECT count(*) FROM talerable_outgoing_transactions) AS talerable - ", - ) - .try_map(|r: PgRow| { - assert_eq!( - (r.try_get_u64(0)?, r.try_get_u64(1)?), - (nb_outgoing, nb_talerable) - ); - Ok(()) - }) - .fetch_one(db) - .await - .unwrap(); + assert_eq!( + sqlx::query_as::<_, (i64, i64)>( + " + SELECT (SELECT count(*) FROM outgoing_transactions), + (SELECT count(*) FROM talerable_outgoing_transactions) + ", + ) + .fetch_one(db) + .await + .unwrap(), + (nb_outgoing as i64, nb_talerable as i64) + ); } pub async fn check_in(db: &PgPool) -> Vec<Status> { diff --git a/libeufin-nexus/src/db/initiated.rs b/libeufin-nexus/src/db/initiated.rs @@ -492,7 +492,7 @@ mod test { #[tokio::test] pub async fn initiated_skip() { let (_, db) = db_setup().await; - let cfg = Config::from_file(CONFIG_SOURCE, Some("conf/skip.conf")).unwrap(); + let cfg = Config::load(CONFIG_SOURCE, Some("conf/skip.conf")).unwrap(); let cfg = NexusCfg::parse(&cfg).unwrap(); let cfg = cfg.ingest().unwrap(); let millis = Span::new().milliseconds(10); diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/cli/Testing.kt @@ -1,254 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. - - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.nexus.cli - -import com.github.ajalt.clikt.core.CliktCommand -import com.github.ajalt.clikt.core.Context -import com.github.ajalt.clikt.core.subcommands -import com.github.ajalt.clikt.parameters.arguments.* -import com.github.ajalt.clikt.parameters.groups.provideDelegate -import com.github.ajalt.clikt.parameters.options.convert -import com.github.ajalt.clikt.parameters.options.default -import com.github.ajalt.clikt.parameters.options.flag -import com.github.ajalt.clikt.parameters.options.option -import com.github.ajalt.clikt.parameters.options.required -import com.github.ajalt.clikt.parameters.types.* -import com.github.ajalt.mordant.terminal.* -import tech.libeufin.common.* -import tech.libeufin.nexus.* -import tech.libeufin.nexus.iso20022.* -import tech.libeufin.ebics.* -import tech.libeufin.ebics.test.txCheck -import java.util.zip.* -import java.time.Instant -import java.io.* - -class Wss : TalerCmd() { - override fun help(context: Context) = "Listen to EBICS instant notification over websocket" - - private val ebicsLog by ebicsLogOption() - - override fun run() = cliCmd(logger) { - nexusConfig(config).withDb { db, cfg -> - val (clientKeys, bankKeys) = expectFullKeys(cfg.ebics) - val client = EbicsClient( - cfg.ebics.host, - httpClient(), - db.ebics, - EbicsLogger(ebicsLog), - clientKeys, - bankKeys - ) - val wssNotifications = listenForNotification(client) - if (wssNotifications != null) { - while (true) { - wssNotifications.receive() - logger.debug("{}", wssNotifications) - } - } - } - } -} - -class FakeIncoming : TalerCmd() { - override fun help(context: Context) = "Genere a fake incoming payment" - - private val amount by option( - "--amount", - help = "The payment amount, credit-payto 'amount' parameter takes the precedence" - ).convert { TalerAmount(it) } - private val creditFee by option( - "--credit-fee", - help = "The payment credit fee" - ).convert { TalerAmount(it) } - private val subject by option( - "--subject", - help = "The payment subject, credit-payto 'message' parameter takes the precedence" - ) - private val chQrr by option( - "--ch-qrr", - help = "The payment reference, credit-payto 'ch-qrr' parameter takes the precedence" - ) - private val creditPayto by option( - "--credit-payto", - help = "The credited account IBAN payto URI" - ).convert { Payto.parse(it).expectIban() } - private val debitPayto by option( - "--debit-payto", - help = "The debited account IBAN payto URI" - ).convert { Payto.parse(it).expectIban() } - private val payto by argument( - name = "deprecated", - help = "deprecated" - ).convert { Payto.parse(it).expectIban() }.optional() - - override fun run() = cliCmd(logger) { - nexusConfig(config).withDb { db, cfg -> - val amount = requireNotNull(creditPayto?.amount ?: payto?.amount ?: amount) { "Missing amount" } - val subject = creditPayto?.message ?: payto?.message ?: subject - val reference = creditPayto?.chQrr ?: payto?.chQrr ?: chQrr - - creditPayto?.let { - if (reference != null) { - require(it.iban == cfg.ebics.qrIban) { - "Creditor must be the exchange QRR account expected ${cfg.ebics.account.iban} got ${it.iban}" - } - } else { - require(it.iban == cfg.ebics.account.iban) { - "Creditor must be the exchange expected ${cfg.ebics.account.iban} got ${it.iban}" - } - } - } - - require(amount.currency == cfg.currency) { - "Wrong currency: expected ${cfg.currency} got ${amount.currency}" - } - registerIncomingPayment( - db, cfg.ingest, - IncomingPayment( - amount = amount, - debtor = debitPayto ?: payto ?: IbanPayto.rand("Testing Account", Country.valueOf(cfg.ebics.account.iban.value.substring(0 until 2))), - subject = reference ?: subject ?: "", - creditFee = creditFee, - executionTime = Instant.now(), - id = IncomingId(null, randEbicsId(), null) - ) - ) - } - } -} - -class TxCheck : TalerCmd() { - override fun help(context: Context) = "Check transaction semantic" - - override fun run() = cliCmd(logger) { - val nexusCgf = nexusConfig(config) - val cfg = nexusCgf.ebics - val (clientKeys, bankKeys) = expectFullKeys(cfg) - val order = cfg.dialect.downloadDoc(OrderDoc.acknowledgement) - val client = httpClient() - val result = txCheck(client, cfg.host, clientKeys, bankKeys, order[0], cfg.dialect.directDebit()) - println("$result") - } -} - -enum class ListKind { - incoming, - outgoing, - initiated; - - fun description(): String = when (this) { - incoming -> "Incoming transactions" - outgoing -> "Outgoing transactions" - initiated -> "Initiated transactions" - } -} - -class EbicsDownload : TalerCmd("ebics-btd") { - override fun help(context: Context) = "Perform EBICS requests" - - private val type by option().default("BTD") - private val name by option() - private val scope by option() - private val messageName by option() - private val messageVersion by option() - private val container by option() - private val option by option() - private val ebicsLog by ebicsLogOption() - private val pinnedStart by option( - help = "Constant YYYY-MM-DD date for the earliest document" + - " to download (only consumed in --transient mode). The" + - " latest document is always until the current time." - ) - private val peek by option( - "--peek", - help = "Do not consume fetched documents" - ).flag() - private val dryRun by option().flag() - - class DryRun : Exception() - - override fun run() = cliCmd(logger) { - nexusConfig(config).withDb { db, cfg -> - val (clientKeys, bankKeys) = expectFullKeys(cfg.ebics) - val pinnedStartVal = pinnedStart - val pinnedStartArg = if (pinnedStartVal != null) { - logger.debug("Pinning start date to: $pinnedStartVal") - dateToInstant(pinnedStartVal) - } else null - val client = EbicsClient( - cfg.ebics.host, - httpClient(), - db.ebics, - EbicsLogger(ebicsLog), - clientKeys, - bankKeys - ) - try { - client.download( - EbicsOrder.V3(type, name, scope, messageName, messageVersion, container, option), - pinnedStartArg, - null, - peek - ) { stream -> - if (container == "ZIP") { - stream.unzipEach { fileName, xmlContent -> - println(fileName) - println(xmlContent.readText()) - } - } else { - println(stream.readText()) - } - if (dryRun) throw DryRun() - } - } catch (e: DryRun) { - // We throw DryRun to not consume files while testing - } - } - } -} - -class IbanGen : CliktCommand("gen") { - override fun help(context: Context) = "Generate fake IBANs for testing" - - private val country by option().enum<Country>().required() - - override fun run() { - println(IBAN.rand(country)) - } -} - -class IbanCmd : CliktCommand("iban") { - init { - subcommands(IbanGen()) - } - - override fun run() = Unit -} - -class TestingCmd : CliktCommand("testing") { - init { - subcommands(IbanCmd(), FakeIncoming(), ListCmd(), EbicsDownload(), TxCheck(), Wss()) - } - - override fun help(context: Context) = "Testing helper commands" - - override fun run() = Unit -} diff --git a/testbench/Cargo.toml b/testbench/Cargo.toml @@ -14,12 +14,18 @@ anyhow.workspace = true jiff.workspace = true clap.workspace = true taler-common.workspace = true +taler-api.workspace = true taler-test-utils.workspace = true -libeufin-nexus = { path = "../libeufin-nexus"} -libeufin-ebisync = { path = "../libeufin-ebisync"} -libeufin-ebics = { path = "../libeufin-ebics"} +sqlx.workspace = true +http-client.workspace = true +axum.workspace = true +libeufin-bank = { path = "../libeufin-bank", features = ["test-utils"] } +libeufin-nexus = { path = "../libeufin-nexus" } +libeufin-ebisync = { path = "../libeufin-ebisync" } +libeufin-ebics = { path = "../libeufin-ebics" } owo-colors = "4.3" tracing-subscriber = "0.3" [dev-dependencies] +serde_json.workspace = true nix = { version = "0.31", features = ["user"] } diff --git a/testbench/conf/integration.conf b/testbench/conf/integration.conf @@ -10,8 +10,6 @@ allow_conversion = YES FIAT_CURRENCY = EUR tan_sms = libeufin-tan-file.sh tan_email = libeufin-tan-fail.sh -SERVE = unix -UNIXPATH = /tmp/libeufin.sock [nexus-ebics] CURRENCY = EUR diff --git a/testbench/src/integration.rs b/testbench/src/integration.rs @@ -0,0 +1,605 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* LibEuFin is free software; you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation; either version 3, or +* (at your option) any later version. + +* LibEuFin is distributed in the hope that it will be useful, but +* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General +* Public License for more details. + +* You should have received a copy of the GNU Affero General Public +* License along with LibEuFin; see the file COPYING. If not, see +* <http://www.gnu.org/licenses/> +*/ + +use std::{assert_matches, time::Duration}; + +use anyhow::anyhow; +use axum::http::Method; +use http_client::client; +use jiff::Timestamp; +use libeufin_bank::api::{ + account::rand_iban_payto, conversion::ConversionResponse, test::BankClient, + tx::BankAccountTransactionsResponse, +}; +use libeufin_ebics::iso20022::model::{InId, InTx}; +use libeufin_nexus::{ + config::{AccountType, NexusIngestCfg}, + fetch::register_incoming, +}; +use sqlx::postgres::PgRow; +use taler_api::db::TypeHelper; +use taler_common::{ + api::{ + EddsaPublicKey, HashCode, ShortHashCode, + wire::{IncomingBankTransaction, IncomingHistory}, + }, + config::Config, + db::pool, + types::{ + amount::{Amount, Currency, amount}, + payto::{FullIbanPayto, PaytoImpl}, + }, +}; +use taler_test_utils::{cli::clap_parse, db::test_db, json, server::TestServer, setup_tracing}; +use tokio::try_join; + +async fn nexus_cmd(cfg: &Config, cmd: &str) { + let tmp: libeufin_nexus::Args = clap_parse(cmd).unwrap(); + + libeufin_nexus::run(cfg, tmp.cmd).await.unwrap() +} + +async fn bank_cmd(cfg: &Config, cmd: &str) { + let tmp: libeufin_bank::Args = clap_parse(cmd).unwrap(); + libeufin_bank::run(cfg, tmp.cmd).await.unwrap(); +} + +pub async fn wait_for_http(url: &str) { + let client = client(); + + for _ in 0..100 { + if http_client::builder::Req::new(&client, Method::GET, &url.parse().unwrap(), "") + .send() + .await + .is_ok() + { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("{url} never becomed active") +} + +#[tokio::test] +async fn mini() { + setup_tracing(); + let db = test_db().await; + let bank_cfg = Config::from_file_override( + libeufin_bank::CONFIG_SOURCE, + Some("conf/mini.conf"), + &format!( + " + [libeufin-bankdb-postgres] + CONFIG = postgresql:///{} + ", + db.get_database().unwrap() + ), + ) + .unwrap(); + let nexus_cfg = Config::from_file_override( + libeufin_nexus::CONFIG_SOURCE, + Some("conf/mini.conf"), + &format!( + " + [libeufin-nexusdb-postgres] + CONFIG = postgresql:///{} + ", + db.get_database().unwrap() + ), + ) + .unwrap(); + + bank_cmd(&bank_cfg, "dbinit -r").await; + bank_cmd(&bank_cfg, "passwd admin admin-password").await; + bank_cmd(&bank_cfg, "dbinit").await; // Idempotent + + // Check bank is running + try_join!( + async { + bank_cmd(&bank_cfg, "serve").await; + Ok(()) + }, + async { + wait_for_http("http://localhost:8080/config").await; + Err::<(), _>(anyhow!("done")) + } + ) + .unwrap_err(); + // Check nexus is running + try_join!( + async { + nexus_cmd(&nexus_cfg, "serve").await; + Ok(()) + }, + async { + wait_for_http("http://localhost:8080/config").await; + Err::<(), _>(anyhow!("done")) + } + ) + .unwrap_err(); + + bank_cmd(&bank_cfg, "gc").await; +} + +#[tokio::test] +async fn errors() { + setup_tracing(); + let db = test_db().await; + let bank_cfg = Config::from_file_override( + libeufin_bank::CONFIG_SOURCE, + Some("conf/integration.conf"), + &format!( + " + [libeufin-bankdb-postgres] + CONFIG = postgresql:///{db} + ", + db = db.get_database().unwrap() + ), + ) + .unwrap(); + + let nexus_cfg = Config::from_file_override( + libeufin_nexus::CONFIG_SOURCE, + Some("conf/integration.conf"), + &format!( + " + [libeufin-nexusdb-postgres] + CONFIG = postgresql:///{db} + ", + db = db.get_database().unwrap() + ), + ) + .unwrap(); + + nexus_cmd(&nexus_cfg, "dbinit -r").await; + bank_cmd(&bank_cfg, "dbinit -r").await; + bank_cmd(&bank_cfg, "passwd admin admin-password").await; + + let cfg = NexusIngestCfg::simple(AccountType::exchange, &Currency::KUDOS); + let user_payto = rand_iban_payto().full("Sir Florian"); + + let mut ctx = + BankClient::new(&bank_cfg, pool(db.clone(), "libeufin_bank").await.unwrap()).await; + let db = pool(db.clone(), "libeufin_nexus").await.unwrap(); + + // Load conversion setup manually as the server would refuse to start without an exchange account + let mut conn = db.acquire().await.unwrap().detach(); + sqlx::raw_sql(include_str!( + "../../database-versioning/libeufin-conversion-setup.sql" + )) + .execute(&mut conn) + .await + .unwrap(); + + let check_count = async |nb_incoming: i64, nb_bounce: i64, nb_talerable: i64| { + assert_eq!( + sqlx::query_as::<_, (i64, i64, i64)>( + " + SELECT (SELECT count(*) FROM incoming_transactions), + (SELECT count(*) FROM bounced_transactions), + (SELECT count(*) FROM talerable_incoming_transactions) + " + ) + .fetch_one(&db) + .await + .unwrap(), + (nb_incoming, nb_bounce, nb_talerable) + ) + }; + + let reserve_pub = EddsaPublicKey::rand(); + let reserve_payment = InTx { + amount: Amount::new(&Currency::KUDOS, 10, 0), + debtor: Some(user_payto.as_uri()), + subject: Some(format!("Error test {reserve_pub}")), + execution_time: Timestamp::now(), + id: InId::new(None, Some("reserve_error".into()), None), + credit_fee: Amount::zero(&Currency::KUDOS), + }; + + // Cashin fails + assert_eq!( + register_incoming(&db, &cfg, &reserve_payment) + .await + .unwrap_err() + .into_database_error() + .unwrap() + .message(), + "cashin failed: missing exchange account" + ); + check_count(0, 0, 0).await; + + // But KYC works + register_incoming( + &db, + &cfg, + &InTx { + id: InId::new(None, Some("KYC".into()), None), + subject: Some(format!("Error test KYC:{}", EddsaPublicKey::rand())), + ..reserve_payment.clone() + }, + ) + .await + .unwrap(); + check_count(1, 0, 1).await; + + // Create exchange account + bank_cmd( + &bank_cfg, + "create-account -u exchange -p exchange-password --name 'Mr Money' --exchange", + ) + .await; + ctx.cache_tokens(&["admin", "exchange"]).await; + + // Missing rates + register_incoming( + &db, + &cfg, + &InTx { + id: InId::new(None, Some("rate_error".into()), None), + ..reserve_payment.clone() + }, + ) + .await + .unwrap(); + check_count(2, 1, 1).await; + + // Set conversion_rate + ctx.post_admin("/conversion-info/conversion-rate") + .json(json!({ + "cashin_ratio": "0.8", + "cashin_fee": "KUDOS:0.02", + "cashin_tiny_amount": "KUDOS:0.01", + "cashin_rounding_mode": "nearest", + "cashin_min_amount": "EUR:0", + "cashout_ratio": "1.25", + "cashout_fee": "EUR:0.003", + "cashout_tiny_amount": "EUR:0.01", + "cashout_rounding_mode": "zero", + "cashout_min_amount": "KUDOS:0.1" + })) + .await + .assert_no_content(); + + // Cashin fails + assert_eq!( + register_incoming(&db, &cfg, &reserve_payment) + .await + .unwrap_err() + .into_database_error() + .unwrap() + .message(), + "cashin failed: admin balance insufficient" + ); + check_count(2, 1, 1).await; + + // Allow admin debt + bank_cmd(&bank_cfg, "edit-account admin --debit_threshold KUDOS:100").await; + + // Too small amount + register_incoming( + &db, + &cfg, + &InTx { + amount: Amount::new(&Currency::KUDOS, 0, 10000), + ..reserve_payment.clone() + }, + ) + .await + .unwrap(); + check_count(3, 2, 1).await; + + ctx.geta("/accounts/exchange/transactions") + .await + .assert_no_content(); + + // Check success + let valid_payment = InTx { + subject: Some(format!("Sucess {reserve_pub}")), + id: InId::new(None, Some("success".into()), None), + ..reserve_payment + }; + register_incoming(&db, &cfg, &valid_payment).await.unwrap(); + check_count(4, 2, 2).await; + ctx.geta("/accounts/exchange/transactions") + .await + .assert_ok(); + + // Check idempotency + register_incoming(&db, &cfg, &valid_payment).await.unwrap(); + register_incoming( + &db, + &cfg, + &InTx { + subject: Some(format!("Sucess 2 {reserve_pub}")), + ..valid_payment + }, + ) + .await + .unwrap(); + check_count(4, 2, 2).await; +} + +#[tokio::test] +async fn conversion() { + setup_tracing(); + let db = test_db().await; + let bank_cfg = Config::from_file_override( + libeufin_bank::CONFIG_SOURCE, + Some("conf/integration.conf"), + &format!( + " + [libeufin-bankdb-postgres] + CONFIG = postgresql:///{db} + ", + db = db.get_database().unwrap() + ), + ) + .unwrap(); + + let nexus_cfg = Config::from_file_override( + libeufin_nexus::CONFIG_SOURCE, + Some("conf/integration.conf"), + &format!( + " + [libeufin-nexusdb-postgres] + CONFIG = postgresql:///{db} + ", + db = db.get_database().unwrap() + ), + ) + .unwrap(); + + let mut ctx = + BankClient::new(&bank_cfg, pool(db.clone(), "libeufin_bank").await.unwrap()).await; + let db = pool(db.clone(), "libeufin_nexus").await.unwrap(); + + nexus_cmd(&nexus_cfg, "dbinit -r").await; + bank_cmd(&bank_cfg, "dbinit -r").await; + bank_cmd(&bank_cfg, "passwd admin admin-password").await; + bank_cmd(&bank_cfg, "edit-account admin --debit_threshold KUDOS:1000").await; + bank_cmd( + &bank_cfg, + "create-account -u exchange -p exchange-password --name 'Mr Money' --exchange", + ) + .await; + nexus_cmd(&nexus_cfg, "dbinit").await; // Idempotent + bank_cmd(&bank_cfg, "dbinit").await; // Idempotent + + // Load conversion setup manually as the server would refuse to start without an exchange account + let mut conn = db.acquire().await.unwrap().detach(); + sqlx::raw_sql(include_str!( + "../../database-versioning/libeufin-conversion-setup.sql" + )) + .execute(&mut conn) + .await + .unwrap(); + + ctx.cache_tokens(&["admin", "exchange"]).await; + + let user_payto = rand_iban_payto().full("Sir Christian"); + let fiat_payto = rand_iban_payto(); + + // Create user + ctx.post_admin("/accounts") + .json(json!({ + "username": "customer", + "password": "customer-password", + "name": "John Smith", + "internal_payto_uri": user_payto, + "cashout_payto_uri": fiat_payto, + "debit_threshold": "KUDOS:100", + "contact_data": { + "phone": "+99" + } + })) + .await + .assert_ok(); + ctx.cache_tokens(&["customer"]).await; + + // Set conversion rates + ctx.post_admin("/conversion-info/conversion-rate") + .json(json!({ + "cashin_ratio": "0.8", + "cashin_fee": "KUDOS:0.02", + "cashin_tiny_amount": "KUDOS:0.01", + "cashin_rounding_mode": "nearest", + "cashin_min_amount": "EUR:0", + "cashout_ratio": "1.25", + "cashout_fee": "EUR:0.003", + "cashout_tiny_amount": "EUR:0.01", + "cashout_rounding_mode": "zero", + "cashout_min_amount": "KUDOS:0.1" + })) + .await + .assert_no_content(); + + let check_initiated = async |amount: &Amount, name: &str| { + sqlx::query( + " + SELECT amount, credit_payto, subject + FROM initiated_outgoing_transactions + ORDER BY initiation_time DESC + ", + ) + .try_map(|r: PgRow| { + assert_eq!(amount, &r.try_get_amount("amount", &amount.currency)?); + let payto = r.try_get_payto("credit_payto")?; + assert_eq!(FullIbanPayto::try_from(&payto).unwrap().name, name); + Ok(()) + }) + .fetch_one(&db) + .await + .unwrap() + }; + + // Cashin + for i in 0..3 { + let key = EddsaPublicKey::rand(); + let amount = amount(format!("EUR:{}", 20 + i)); + let subject = format!("cashin test {i}: {key}"); + nexus_cmd( + &nexus_cfg, + &format!( + "testing fake-incoming --subject \"{subject}\" --amount {amount} {user_payto}" + ), + ) + .await; + let converted = ctx + .get(format!( + "/conversion-info/cashin-rate?amount_debit={amount}" + )) + .await + .assert_ok_json::<ConversionResponse>() + .amount_credit; + let tx = ctx + .geta("/accounts/exchange/transactions") + .await + .assert_ok_json::<BankAccountTransactionsResponse>() + .transactions + .remove(0); + assert_eq!(tx.subject, subject); + assert_eq!(tx.amount, converted); + let tx = ctx + .geta("/accounts/exchange/taler-wire-gateway/history/incoming") + .await + .assert_ok_json::<IncomingHistory>() + .incoming_transactions + .remove(0); + assert_matches!(tx, IncomingBankTransaction::Reserve { amount, reserve_pub, .. } if amount == converted && reserve_pub == key); + } + + // Cashout + for i in 0..3 { + let request_uid = ShortHashCode::rand(); + let amount = amount(format!("KUDOS:{}", 10 + i)); + let converted = ctx.convert(&amount).await; + ctx.posta("/accounts/customer/cashouts") + .json(json!( { + "request_uid" : request_uid, + "amount_debit" : amount, + "amount_credit" : converted, + })) + .await + .assert_ok(); + check_initiated(&converted, "John Smith").await; + } + + // Exchange bounce no name + for i in 0..3 { + let key = EddsaPublicKey::rand(); + let amount = amount(format!("EUR:{}", 30 + i)); + let subject = format!("cashin test {i}: {key}"); + + // Cashin + nexus_cmd( + &nexus_cfg, + &format!( + "testing fake-incoming --subject \"{subject}\" --amount {amount} {user_payto}" + ), + ) + .await; + let converted = ctx + .get(format!( + "/conversion-info/cashin-rate?amount_debit={amount}" + )) + .await + .assert_ok_json::<ConversionResponse>() + .amount_credit; + let tx = ctx + .geta("/accounts/exchange/transactions") + .await + .assert_ok_json::<BankAccountTransactionsResponse>() + .transactions + .remove(0); + assert_eq!(tx.subject, subject); + assert_eq!(tx.amount, converted); + let tx = ctx + .geta("/accounts/exchange/taler-wire-gateway/history/incoming") + .await + .assert_ok_json::<IncomingHistory>() + .incoming_transactions + .remove(0); + assert_matches!(tx, IncomingBankTransaction::Reserve { amount, reserve_pub, .. } if amount == converted && reserve_pub == key); + + // Bounce + ctx.posta("/accounts/exchange/taler-wire-gateway/transfer") + .json(json!({ + "request_uid" : HashCode::rand(), + "amount" : converted, + "exchange_base_url" : "http://exchange.example.com/", + "wtid" : key, + "credit_account" : "payto://x-taler-bank/localhost/admin", + })) + .await + .assert_ok(); + check_initiated(&amount, "Sir Christian").await; + } + + // Exchange bounce + for i in 0..3 { + let key = EddsaPublicKey::rand(); + let amount = amount(format!("EUR:{}", 30 + i)); + let subject = format!("exchange bounce test {i}: {key}"); + + // Cashin + nexus_cmd( + &nexus_cfg, + &format!( + "testing fake-incoming --subject \"{subject}\" --amount {amount} {user_payto}" + ), + ) + .await; + let converted = ctx + .get(format!( + "/conversion-info/cashin-rate?amount_debit={amount}" + )) + .await + .assert_ok_json::<ConversionResponse>() + .amount_credit; + let tx = ctx + .geta("/accounts/exchange/transactions") + .await + .assert_ok_json::<BankAccountTransactionsResponse>() + .transactions + .remove(0); + assert_eq!(tx.subject, subject); + assert_eq!(tx.amount, converted); + let tx = ctx + .geta("/accounts/exchange/taler-wire-gateway/history/incoming") + .await + .assert_ok_json::<IncomingHistory>() + .incoming_transactions + .remove(0); + assert_matches!(tx, IncomingBankTransaction::Reserve { amount, reserve_pub, .. } if amount == converted && reserve_pub == key); + + // Bounce + ctx.posta("/accounts/exchange/taler-wire-gateway/transfer") + .json(json!({ + "request_uid" : HashCode::rand(), + "amount" : converted, + "exchange_base_url" : "http://exchange.example.com/", + "wtid" : key, + "credit_account" : "payto://x-taler-bank/localhost/admin", + })) + .await + .assert_ok(); + check_initiated(&amount, "Sir Christian").await; + } +} diff --git a/testbench/src/lib.rs b/testbench/src/lib.rs @@ -0,0 +1,360 @@ +/* +* This file is part of LibEuFin. +* Copyright (C) 2026 Taler Systems S.A. + +* LibEuFin is free software; you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation; either version 3, or +* (at your option) any later version. + +* LibEuFin is distributed in the hope that it will be useful, but +* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General +* Public License for more details. + +* You should have received a copy of the GNU Affero General Public +* License along with LibEuFin; see the file COPYING. If not, see +* <http://www.gnu.org/licenses/> +*/ + +use taler_common::config::Config; + +#[cfg(test)] +mod integration; + +pub fn full_cfg(base: &str, mem: &str) -> Config { + let mut parser = taler_common::config::parser::Parser::empty(); + parser.load_env(libeufin_bank::CONFIG_SOURCE).unwrap(); + parser.load_env(libeufin_nexus::CONFIG_SOURCE).unwrap(); + parser + .load_env(libeufin_ebisync::config::CONFIG_SOURCE) + .unwrap(); + parser.parse_file(base.into(), 0).unwrap(); + parser.parse_str(mem).unwrap(); + parser.finish() +} + +#[cfg(test)] +mod test { + use std::{fs::Permissions, io::Write, os::unix::fs::PermissionsExt as _, path::PathBuf}; + + use libeufin_ebics::keys::{BankKeys, ClientKeys, persist_bank_keys, persist_client_keys}; + use libeufin_nexus::config::{NexusCfg, NexusKeysCfg}; + use sqlx::PgPool; + use taler_test_utils::cli::clap_parse; + + use crate::full_cfg; + + /** Test error format related to the keying process */ + #[tokio::test] + async fn keys() { + let nexus_cmds = ["ebics-submit", "ebics-fetch"]; + let nexus_all_cmds = ["ebics-submit", "ebics-fetch", "ebics-setup"]; + let sync_cmds = ["fetch"]; + let sync_all_cmds = ["fetch", "setup"]; + let cfg = full_cfg("conf/cli.conf", ""); + let cfg = NexusCfg::parse(&cfg).unwrap(); + let NexusKeysCfg { client, bank } = cfg.keys().unwrap(); + + let c_path = &PathBuf::from(client); + let b_path = &PathBuf::from(bank); + std::fs::create_dir_all(c_path.parent().unwrap()).unwrap(); + std::fs::create_dir_all(b_path.parent().unwrap()).unwrap(); + + let is_root = nix::unistd::Uid::effective().is_root(); + + let check_cmds = async |msg: &str| { + for cmd in nexus_cmds { + let tmp: libeufin_nexus::Args = clap_parse(cmd).unwrap(); + let res = libeufin_nexus::run(cfg.cfg, tmp.cmd).await.unwrap_err(); + assert_eq!( + res.to_string(), + msg.replace("SETUPCMD", "libeufin-nexus ebics-setup") + ); + } + for cmd in sync_cmds { + let tmp: libeufin_ebisync::Args = clap_parse(cmd).unwrap(); + let res = libeufin_ebisync::run(cfg.cfg, tmp.cmd).await.unwrap_err(); + assert_eq!( + res.to_string(), + msg.replace("SETUPCMD", "libeufin-ebisync setup") + ); + } + }; + let check_all_cmds = async |msg: &str| { + for cmd in nexus_all_cmds { + let tmp: libeufin_nexus::Args = clap_parse(cmd).unwrap(); + let res = libeufin_nexus::run(cfg.cfg, tmp.cmd).await.unwrap_err(); + assert_eq!( + res.to_string(), + msg.replace("SETUPCMD", "libeufin-nexus ebics-setup") + ); + } + for cmd in sync_all_cmds { + let tmp: libeufin_ebisync::Args = clap_parse(cmd).unwrap(); + let res = libeufin_ebisync::run(cfg.cfg, tmp.cmd).await.unwrap_err(); + assert_eq!( + res.to_string(), + msg.replace("SETUPCMD", "libeufin-ebisync setup") + ); + } + }; + + // Missing client keys + std::fs::remove_file(c_path).ok(); + check_cmds(&format!( + "Missing client private keys file at '{client}', run 'SETUPCMD' first" + )) + .await; + // Empty client file + let mut cf = std::fs::File::create(c_path).unwrap(); + check_all_cmds(&format!( + "Could not read client private keys at '{client}': unexpected end of file" + )) + .await; + // Bad client json + cf.write_all(b"CORRUPTION").unwrap(); + check_all_cmds(&format!( + "Could not read client private keys at '{client}': invalid data" + )) + .await; + if !is_root { + // Missing permission + cf.set_permissions(Permissions::from_mode(0o000)).unwrap(); + check_all_cmds(&format!( + "Could not read client private keys at '{client}': permission denied" + )) + .await; + } + // Unfinished client + let client_keys = ClientKeys::generate().unwrap(); + persist_client_keys(&client_keys, c_path).unwrap(); + check_cmds("Unsubmitted client private keys, run 'SETUPCMD' first").await; + + // Missing bank keys + persist_client_keys( + &ClientKeys { + submitted_ini: true, + submitted_hia: true, + ..client_keys + }, + c_path, + ) + .unwrap(); + std::fs::remove_file(b_path).ok(); + check_cmds(&format!( + "Missing bank public keys file at '{bank}', run 'SETUPCMD' first" + )) + .await; + // Empty bank file + let mut bf = std::fs::File::create(b_path).unwrap(); + check_all_cmds(&format!( + "Could not read bank public keys at '{bank}': unexpected end of file" + )) + .await; + // Bad bank json + bf.write_all(b"CORRUPTION").unwrap(); + check_all_cmds(&format!( + "Could not read bank public keys at '{bank}': invalid data" + )) + .await; + if !is_root { + // Missing permission + bf.set_permissions(Permissions::from_mode(0o000)).unwrap(); + check_all_cmds(&format!( + "Could not read bank public keys at '{bank}': permission denied" + )) + .await; + } + // Unfinished bank + let bank_keys = BankKeys::generate(); + persist_bank_keys(&bank_keys, b_path).unwrap(); + check_cmds("Unaccepted bank public keys, run 'SETUPCMD' until accepting the bank keys") + .await; + } + + #[tokio::test] + async fn migration() { + let db = PgPool::connect("postgres:///taler_rust_check") + .await + .unwrap(); + + let execute = async |sql: &str| { + sqlx::raw_sql(sql).execute(&db).await.unwrap(); + }; + let execute_fs = async |file: &str| { + sqlx::raw_sql( + &std::fs::read_to_string(format!("../database-versioning/{file}")).unwrap(), + ) + .execute(&db) + .await + .unwrap(); + }; + + // Drop current schemas + execute_fs("libeufin-bank-drop.sql").await; + execute_fs("libeufin-nexus-drop.sql").await; + + // libeufin-bank + execute_fs("libeufin-bank-0001.sql").await; + execute(" + INSERT INTO libeufin_bank.customers (login, password_hash) VALUES + ('account_1', 'fack_hash'), + ('account_2', 'fack_hash'), + ('account_3', 'fack_hash'), + ('account_4', 'fack_hash'); + INSERT INTO libeufin_bank.bank_accounts (internal_payto_uri, owning_customer_id) VALUES + ('payto_1', 1), + ('payto_2', 2), + ('payto_3', 3), + ('payto_4', 4); + INSERT INTO libeufin_bank.bank_account_transactions(creditor_payto_uri, creditor_name, debtor_payto_uri, debtor_name, subject, amount, transaction_date, direction, bank_account_id) VALUES + ('payto_1', 'account_1', 'payto_2', 'account_2', 'subject', (0, 0), 42, 'credit', 1), + ('payto_1', 'account_1', 'payto_2', 'account_2', 'subject', (0, 0), 42, 'credit', 1), + ('payto_1', 'account_1', 'payto_2', 'account_2', 'subject', (0, 0), 42, 'credit', 1); + INSERT INTO libeufin_bank.taler_exchange_incoming(reserve_pub, bank_transaction) VALUES + ('\\x6ca1ab1a76a484d7424064c51c49c1947405f42f7d185d052dbf6718d845ec6b'::bytea, 1), + ('\\xa605637a4852684e4957e6177f41311eacf8661a6a74b90178c487fe347b9918'::bytea, 2); + INSERT INTO libeufin_bank.challenges(code, creation_date, expiration_date, retry_counter) VALUES + ('secret_code', 42, 42, 42), + ('secret_code', 42, 42, 42); + INSERT INTO libeufin_bank.cashout_operations(request_uid, amount_debit, amount_credit, subject, creation_time, bank_account, challenge, local_transaction) VALUES + ('\\x6ca1ab1a76a484d7424064c51c49c1947405f42f7d185d052dbf6718d845ec6b'::bytea, (0, 0), (0, 0), 'subject', 42, 1, 1, 1), + ('\\xa605637a4852684e4957e6177f41311eacf8661a6a74b90178c487fe347b9918'::bytea, (0, 0), (0, 0), 'subject', 42, 1, 2, NULL); + INSERT INTO libeufin_bank.taler_withdrawal_operations(withdrawal_uuid, amount, reserve_pub, wallet_bank_account) VALUES + (gen_random_uuid(), (0, 0), '\\x6ca1ab1a76a484d7424064c51c49c1947405f42f7d185d052dbf6718d845ec6b'::bytea, 1), + (gen_random_uuid(), (0, 0), '\\xa605637a4852684e4957e6177f41311eacf8661a6a74b90178c487fe347b9918'::bytea, 2); + ").await; + execute_fs("libeufin-bank-0002.sql").await; + execute_fs("libeufin-bank-0003.sql").await; + execute_fs("libeufin-bank-0004.sql").await; + execute( + " + UPDATE libeufin_bank.bank_accounts SET min_cashout=(0, 1) WHERE bank_account_id=2; + UPDATE libeufin_bank.bank_accounts SET min_cashout=(2, 300) WHERE bank_account_id IN (3, 4); + ", + ) + .await; + execute_fs("libeufin-bank-0005.sql").await; + execute_fs("libeufin-bank-0006.sql").await; + execute_fs("libeufin-bank-0007.sql").await; + execute_fs("libeufin-bank-0008.sql").await; + execute_fs("libeufin-bank-0009.sql").await; + execute_fs("libeufin-bank-0010.sql").await; + execute_fs("libeufin-bank-0011.sql").await; + execute_fs("libeufin-bank-0012.sql").await; + execute( + r#" + INSERT INTO libeufin_bank.config(key, value) VALUES + ('cashin_ratio', '{"val": 1, "frac": 2}'::jsonb), + ('cashin_fee', '{"val": 3, "frac": 4}'::jsonb), + ('cashin_tiny_amount', '{"val": 5, "frac": 6}'::jsonb), + ('cashin_min_amount', '{"val": 7, "frac": 8}'::jsonb), + ('cashin_rounding_mode', '{"mode": "zero"}'::jsonb), + ('cashout_ratio', '{"val": 9, "frac": 10}'::jsonb), + ('cashout_fee', '{"val": 11, "frac": 12}'::jsonb), + ('cashout_tiny_amount', '{"val": 13, "frac": 14}'::jsonb), + ('cashout_min_amount', '{"val": 15, "frac": 16}'::jsonb), + ('cashout_rounding_mode', '{"mode": "nearest"}'::jsonb); + "#, + ) + .await; + execute_fs("libeufin-bank-0013.sql").await; + assert!( + sqlx::query_scalar::<_, bool>( + r#" + SELECT value='{ + "cashin": { + "fee": { + "val": 3, + "frac": 4 + }, + "ratio": { + "val": 1, + "frac": 2 + }, + "min_amount": { + "val": 7, + "frac": 8 + }, + "tiny_amount": { + "val": 5, + "frac": 6 + }, + "rounding_mode": "zero" + }, + "cashout": { + "fee": { + "val": 11, + "frac": 12 + }, + "ratio": { + "val": 9, + "frac": 10 + }, + "min_amount": { + "val": 15, + "frac": 16 + }, + "tiny_amount": { + "val": 13, + "frac": 14 + }, + "rounding_mode": "nearest" + } + }'::jsonb FROM libeufin_bank.config WHERE key='conversion_rate' + "# + ) + .fetch_one(&db) + .await + .unwrap() + ); + execute_fs("libeufin-bank-0014.sql").await; + execute_fs("libeufin-bank-0015.sql").await; + + // libeufin-nexus + execute_fs("libeufin-nexus-0001.sql").await; + execute(r#" + INSERT INTO libeufin_nexus.outgoing_transactions(amount, execution_time, message_id) VALUES + ((0, 0), 42, 'id'); + INSERT INTO libeufin_nexus.initiated_outgoing_transactions(amount, wire_transfer_subject, initiation_time, credit_payto_uri, outgoing_transaction_id, request_uid) VALUES + ((0, 0), 'subject', 42, 'payto_0', 1, 'request_uid'); + "#, + ) + .await; + execute_fs("libeufin-nexus-0002.sql").await; + execute_fs("libeufin-nexus-0003.sql").await; + execute_fs("libeufin-nexus-0004.sql").await; + execute_fs("libeufin-nexus-0005.sql").await; + execute_fs("libeufin-nexus-0006.sql").await; + execute(r#" + INSERT INTO initiated_outgoing_transactions(amount, wire_transfer_subject, initiation_time, credit_payto_uri, outgoing_transaction_id, request_uid, order_id) VALUES + ((42, 0), 'subject', 0, 'credit_payto', NULL, 'TX0', 'ORDER0'), + ((41, 0), 'subject', 0, 'credit_payto', NULL, 'TX1', NULL); + "#, + ) + .await; + execute_fs("libeufin-nexus-0007.sql").await; + execute_fs("libeufin-nexus-0008.sql").await; + execute_fs("libeufin-nexus-0009.sql").await; + execute(r#" + INSERT INTO libeufin_nexus.incoming_transactions(amount, subject, execution_time, debit_payto, bank_id) VALUES + ((1, 0), 'simple', 42, 'debit_payto', 'first'), + ((2, 0), 'reserve', 42, 'debit_payto', 'second'), + ((3, 0), 'kyc', 42, 'debit_payto', 'third'), + ((4, 0), 'simple', 42, 'debit_payto', gen_random_uuid()::text), + ((5, 0), 'reserve', 42, 'debit_payto', gen_random_uuid()::text), + ((6, 0), 'kyc', 42, 'debit_payto', gen_random_uuid()::text); + INSERT INTO libeufin_nexus.talerable_incoming_transactions(incoming_transaction_id, type, reserve_public_key, account_pub) VALUES + (2, 'reserve', '\x6ca1ab1a76a484d7424064c51c49c1947405f42f7d185d052dbf6718d845ec6b'::bytea, null), + (3, 'kyc', null, '\x6ca1ab1a76a484d7424064c51c49c1947405f42f7d185d052dbf6718d845ec6b'::bytea); + "#, + ) + .await; + execute_fs("libeufin-nexus-0010.sql").await; + execute_fs("libeufin-nexus-0011.sql").await; + execute_fs("libeufin-nexus-0012.sql").await; + execute_fs("libeufin-nexus-0013.sql").await; + execute_fs("libeufin-nexus-0014.sql").await; + } +} diff --git a/testbench/src/main.rs b/testbench/src/main.rs @@ -27,6 +27,7 @@ use libeufin_nexus::config::NexusCfg; use owo_colors::OwoColorize as _; use taler_common::{config::Config, log::taler_logger, types::payto::TransferIbanPayto}; use taler_test_utils::{cli::clap_parse, repl::test_repl}; +use testbench::full_cfg; use tracing::Level; use tracing_subscriber::util::SubscriberInitExt as _; @@ -179,51 +180,45 @@ async fn main() -> anyhow::Result<()> { // Augment config let simple_cfg = std::fs::read_to_string(format!("testbench/test/platform/{}.conf", cmd.platform)).unwrap(); - let cfg = { - let mut parser = taler_common::config::parser::Parser::empty(); - parser.load_env(libeufin_nexus::CONFIG_SOURCE).unwrap(); - parser - .load_env(libeufin_ebisync::config::CONFIG_SOURCE) - .unwrap(); - parser.parse_str(&format!( + let cfg = full_cfg( + &format!("testbench/test/platform/{}.conf", cmd.platform), + &format!( r#" - {simple_cfg} - {} - [paths] - LIBEUFIN_NEXUS_HOME = testbench/test/{} - EBISYNC_HOME = testbench/test/{} - - [nexus-fetch] - FREQUENCY = 1h - CHECKPOINT_TIME_OF_DAY = 16:52 - - [ebisync-fetch] - FREQUENCY = 1h - CHECKPOINT_TIME_OF_DAY = 16:52 - DESTINATION = azure-blob-storage - AZURE_API_URL = http://localhost:10000/devstoreaccount1/ - AZURE_ACCOUNT_NAME = devstoreaccount1 - AZURE_ACCOUNT_KEY = Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== - AZURE_CONTAINER = test - - [ebisync-submit] - SOURCE = ebisync-api - AUTH_METHOD = none - - [libeufin-nexusdb-postgres] - CONFIG = postgres:///libeufintestbench - - [ebisyncdb-postgres] - CONFIG = postgres:///libeufintestbench - "#, + {} + [paths] + LIBEUFIN_NEXUS_HOME = testbench/test/{} + EBISYNC_HOME = testbench/test/{} + + [nexus-fetch] + FREQUENCY = 1h + CHECKPOINT_TIME_OF_DAY = 16:52 + + [ebisync-fetch] + FREQUENCY = 1h + CHECKPOINT_TIME_OF_DAY = 16:52 + DESTINATION = azure-blob-storage + AZURE_API_URL = http://localhost:10000/devstoreaccount1/ + AZURE_ACCOUNT_NAME = devstoreaccount1 + AZURE_ACCOUNT_KEY = Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== + AZURE_CONTAINER = test + + [ebisync-submit] + SOURCE = ebisync-api + AUTH_METHOD = none + + [libeufin-nexusdb-postgres] + CONFIG = postgres:///libeufintestbench + + [ebisyncdb-postgres] + CONFIG = postgres:///libeufintestbench + "#, simple_cfg .replace("[nexus-ebics]", "[ebisync]") .replace("[nexus-setup]", "[ebisync-setup]"), cmd.platform, cmd.platform - )).unwrap(); - parser.finish() - }; + ), + ); let cfg = NexusCfg::parse(&cfg).unwrap(); let ebics = cfg.keys().unwrap(); let (name, settings) = match cfg.host().unwrap().base_url.as_str() { @@ -370,150 +365,3 @@ async fn main() -> anyhow::Result<()> { } Ok(()) } - -#[cfg(test)] -mod test { - use std::{fs::Permissions, io::Write, os::unix::fs::PermissionsExt as _, path::PathBuf}; - - use libeufin_ebics::keys::{BankKeys, ClientKeys, persist_bank_keys, persist_client_keys}; - use libeufin_nexus::config::{NexusCfg, NexusKeysCfg}; - - use crate::clap_parse; - - /** Test error format related to the keying process */ - #[tokio::test] - pub async fn keys() { - let nexus_cmds = ["ebics-submit", "ebics-fetch"]; - let nexus_all_cmds = ["ebics-submit", "ebics-fetch", "ebics-setup"]; - let sync_cmds = ["fetch"]; - let sync_all_cmds = ["fetch", "setup"]; - let conf = "conf/cli.conf"; - let cfg = { - let mut parser = taler_common::config::parser::Parser::empty(); - parser.load_env(libeufin_nexus::CONFIG_SOURCE).unwrap(); - parser - .load_env(libeufin_ebisync::config::CONFIG_SOURCE) - .unwrap(); - parser.parse_file(conf.into(), 0).unwrap(); - parser.finish() - }; - let cfg = NexusCfg::parse(&cfg).unwrap(); - let NexusKeysCfg { client, bank } = cfg.keys().unwrap(); - - let c_path = &PathBuf::from(client); - let b_path = &PathBuf::from(bank); - std::fs::create_dir_all(c_path.parent().unwrap()).unwrap(); - std::fs::create_dir_all(b_path.parent().unwrap()).unwrap(); - - let is_root = nix::unistd::Uid::effective().is_root(); - - let check_cmds = async |msg: &str| { - for cmd in nexus_cmds { - let tmp: libeufin_nexus::Args = clap_parse(cmd).unwrap(); - let res = libeufin_nexus::run(cfg.cfg, tmp.cmd).await.unwrap_err(); - assert_eq!( - res.to_string(), - msg.replace("SETUPCMD", "libeufin-nexus ebics-setup") - ); - } - for cmd in sync_cmds { - let tmp: libeufin_ebisync::Args = clap_parse(cmd).unwrap(); - let res = libeufin_ebisync::run(cfg.cfg, tmp.cmd).await.unwrap_err(); - assert_eq!( - res.to_string(), - msg.replace("SETUPCMD", "libeufin-ebisync setup") - ); - } - }; - let check_all_cmds = async |msg: &str| { - for cmd in nexus_all_cmds { - let tmp: libeufin_nexus::Args = clap_parse(cmd).unwrap(); - let res = libeufin_nexus::run(cfg.cfg, tmp.cmd).await.unwrap_err(); - assert_eq!( - res.to_string(), - msg.replace("SETUPCMD", "libeufin-nexus ebics-setup") - ); - } - for cmd in sync_all_cmds { - let tmp: libeufin_ebisync::Args = clap_parse(cmd).unwrap(); - let res = libeufin_ebisync::run(cfg.cfg, tmp.cmd).await.unwrap_err(); - assert_eq!( - res.to_string(), - msg.replace("SETUPCMD", "libeufin-ebisync setup") - ); - } - }; - - // Missing client keys - std::fs::remove_file(c_path).ok(); - check_cmds(&format!( - "Missing client private keys file at '{client}', run 'SETUPCMD' first" - )) - .await; - // Empty client file - let mut cf = std::fs::File::create(c_path).unwrap(); - check_all_cmds(&format!( - "Could not read client private keys at '{client}': unexpected end of file" - )) - .await; - // Bad client json - cf.write_all(b"CORRUPTION").unwrap(); - check_all_cmds(&format!( - "Could not read client private keys at '{client}': invalid data" - )) - .await; - if !is_root { - // Missing permission - cf.set_permissions(Permissions::from_mode(0o000)).unwrap(); - check_all_cmds(&format!( - "Could not read client private keys at '{client}': permission denied" - )) - .await; - } - // Unfinished client - let client_keys = ClientKeys::generate().unwrap(); - persist_client_keys(&client_keys, c_path).unwrap(); - check_cmds("Unsubmitted client private keys, run 'SETUPCMD' first").await; - - // Missing bank keys - persist_client_keys( - &ClientKeys { - submitted_ini: true, - submitted_hia: true, - ..client_keys - }, - c_path, - ) - .unwrap(); - std::fs::remove_file(b_path).ok(); - check_cmds(&format!( - "Missing bank public keys file at '{bank}', run 'SETUPCMD' first" - )) - .await; - // Empty bank file - let mut bf = std::fs::File::create(b_path).unwrap(); - check_all_cmds(&format!( - "Could not read bank public keys at '{bank}': unexpected end of file" - )) - .await; - // Bad bank json - bf.write_all(b"CORRUPTION").unwrap(); - check_all_cmds(&format!( - "Could not read bank public keys at '{bank}': invalid data" - )) - .await; - if !is_root { - // Missing permission - bf.set_permissions(Permissions::from_mode(0o000)).unwrap(); - check_all_cmds(&format!( - "Could not read bank public keys at '{bank}': permission denied" - )) - .await; - } - // Unfinished bank - let bank_keys = BankKeys::generate(); - persist_bank_keys(&bank_keys, b_path).unwrap(); - check_cmds("Unaccepted bank public keys, run 'SETUPCMD' until accepting the bank keys") - .await; - } -}