commit 21aee9fc8810296ccf5cbebb8d28e11030fa7d5d
parent 46e8184ce90eeeeb2a4f6e15a5116de977e67d4a
Author: Antoine A <>
Date: Tue, 16 Jun 2026 12:34:06 +0200
common: add integration tests
Diffstat:
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;
- }
-}