commit 2d74f521631a6cf1c4f4797b7c46767e8b0f1d5c
parent 2e94ff95b271fa0c530d4e1ca0d5efa3878eea34
Author: Antoine A <>
Date: Wed, 27 May 2026 17:13:53 +0200
nexus: finish setup
Diffstat:
14 files changed, 716 insertions(+), 408 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1340,9 +1340,9 @@ dependencies = [
[[package]]
name = "hyper"
-version = "1.9.0"
+version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
+checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc"
dependencies = [
"atomic-waker",
"bytes",
@@ -1798,8 +1798,6 @@ dependencies = [
"const_format",
"jiff",
"libeufin-ebics",
- "owo-colors",
- "reedline",
"regex",
"serde",
"serde_json",
@@ -1812,7 +1810,6 @@ dependencies = [
"taler-test-utils",
"tokio",
"tracing",
- "tracing-subscriber",
"url",
"uuid",
"zip 8.6.0",
@@ -1835,14 +1832,14 @@ dependencies = [
[[package]]
name = "libredox"
-version = "0.1.16"
+version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
+checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [
"bitflags",
"libc",
"plain",
- "redox_syscall 0.7.5",
+ "redox_syscall 0.8.0",
]
[[package]]
@@ -2501,9 +2498,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
-version = "0.7.5"
+version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"
+checksum = "7c7591fa2c6b601dfcfe5f043f65a1c39fcdf50efefcd7f1572e538c1f4b398d"
dependencies = [
"bitflags",
]
@@ -3496,6 +3493,24 @@ dependencies = [
]
[[package]]
+name = "testbench"
+version = "1.5.0"
+dependencies = [
+ "anyhow",
+ "clap",
+ "jiff",
+ "libeufin-ebics",
+ "libeufin-nexus",
+ "owo-colors",
+ "reedline",
+ "shlex 2.0.1",
+ "taler-common",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/crates/libeufin-ebics/src/dialect.rs b/crates/libeufin-ebics/src/dialect.rs
@@ -161,15 +161,15 @@ impl Standard {
}
}
- /*
-
- /** All orders required for a dialect implementation to work */
- fun downloadOrders(): Set<EbicsOrder> = (
+ /** All orders required for a dialect implementation to work */
+ pub fn required(&self) -> Vec<Order> {
// Administrative orders
- sequenceOf(EbicsOrder.V3.HAA, EbicsOrder.V3.HKD)
- // and documents orders
- + OrderDoc.entries.flatMap { downloadDoc(it) }
- ).toSet() */
+ [Order::HAA, Order::HKD]
+ .into_iter()
+ // and documents orders
+ .chain(OrderDoc::entries.iter().flat_map(|o| self.downloads(o)))
+ .collect()
+ }
}
/** Supported bank dialects */
diff --git a/crates/libeufin-ebics/src/keys.rs b/crates/libeufin-ebics/src/keys.rs
@@ -141,6 +141,16 @@ pub struct BankKeys {
pub accepted: bool,
}
+impl BankKeys {
+ pub fn generate() -> Self {
+ Self {
+ enc: RsaPub::generate(),
+ auth: RsaPub::generate(),
+ accepted: false,
+ }
+ }
+}
+
fn ser_pkcs8<S, K>(key: &K, serializer: S) -> Result<S::Ok, S::Error>
where
K: AsDer<Pkcs8V1Der<'static>>,
@@ -216,8 +226,10 @@ pub fn load_client_keys(path: &Path) -> anyhow::Result<Option<ClientKeys>> {
}
/// Load client and bank keys from disk and checks that the keying process has been fully completed
-pub fn expect_full_keys(cfg: &EbicsKeysCfg) -> anyhow::Result<(ClientKeys, BankKeys)> {
- let setup_cmd = "TODO";
+pub fn expect_full_keys(
+ cfg: &EbicsKeysCfg,
+ setup_cmd: &str,
+) -> anyhow::Result<(ClientKeys, BankKeys)> {
let client_keys = load_client_keys(cfg.client.as_ref())?;
let Some(client_keys) = client_keys else {
bail!(
@@ -248,7 +260,7 @@ mod test {
use serde_json::json;
use crate::keys::{
- BankKeys, ClientKeys, RsaPub, load_bank_keys, load_client_keys, persist_bank_keys,
+ BankKeys, ClientKeys, load_bank_keys, load_client_keys, persist_bank_keys,
persist_client_keys,
};
@@ -256,11 +268,7 @@ mod test {
#[test]
fn bank() {
let path: &Path = "/tmp/nexus-tests-bank-keys.json".as_ref();
- let keys = BankKeys {
- enc: RsaPub::generate(),
- auth: RsaPub::generate(),
- accepted: true,
- };
+ let keys = BankKeys::generate();
if std::fs::exists(path).unwrap() {
std::fs::remove_file(path).unwrap()
}
diff --git a/crates/libeufin-ebics/src/pdf.rs b/crates/libeufin-ebics/src/pdf.rs
@@ -90,7 +90,7 @@ pub fn generate_keys_pdf(keys: &ClientKeys, cfg: &EbicsHostCfg) -> anyhow::Resul
"Modulus:",
pub_key.modulus().big_endian_without_leading_zero(),
),
- ("SHA-256 hash:", ebics_pub_key_hash(&pub_key).as_ref()),
+ ("SHA-256 hash:", ebics_pub_key_hash(pub_key).as_ref()),
] {
line(&bld, &mut y, 13.0, hdr);
for chunk in bytes.chunks(16) {
@@ -110,7 +110,7 @@ pub fn generate_keys_pdf(keys: &ClientKeys, cfg: &EbicsHostCfg) -> anyhow::Resul
#[test]
fn test() {
- let bytes = generate_keys_pdf(
+ generate_keys_pdf(
&ClientKeys::generate().unwrap(),
&EbicsHostCfg {
base_url: "https://isotest.postfinance.ch/ebicsweb/ebicsweb",
@@ -121,5 +121,4 @@ fn test() {
},
)
.unwrap();
- std::fs::write("tmp12.pdf", bytes).unwrap()
}
diff --git a/crates/libeufin-ebics/src/setup.rs b/crates/libeufin-ebics/src/setup.rs
@@ -20,6 +20,7 @@
use std::path::Path;
use anyhow::{anyhow, bail};
+use compact_str::CompactString;
use tracing::{debug, info};
use crate::{
@@ -153,7 +154,10 @@ pub async fn ebics_setup(
hex_chunk_by_two(enc_hash),
hex_chunk_by_two(auth_hash)
);
- bank.accepted = dialoguer::Confirm::new().interact()?
+ bank.accepted = dialoguer::Input::<CompactString>::new()
+ .with_prompt("type 'yes, accept' to accept them")
+ .interact()?
+ == "yes, accept";
}
if !bank.accepted {
bail!("Cannot successfully finish the setup without accepting the bank keys");
diff --git a/crates/libeufin-ebics/src/test.rs b/crates/libeufin-ebics/src/test.rs
@@ -570,6 +570,8 @@ impl TestBank {
impl Drop for TestBank {
fn drop(&mut self) {
- assert_eq!(self.sequence.lock().unwrap().len(), 0);
+ if let Ok(sequences) = self.sequence.lock() {
+ assert_eq!(sequences.len(), 0);
+ }
}
}
diff --git a/crates/libeufin-nexus/Cargo.toml b/crates/libeufin-nexus/Cargo.toml
@@ -25,13 +25,10 @@ serde.workspace = true
sqlx.workspace = true
compact_str.workspace = true
uuid.workspace = true
+shlex = "2.0"
url = "2.5"
-reedline = "0.47"
regex = "1.12"
const_format = { version = "0.2", features = ["rust_1_83"] }
zip = { version = "8.5", default-features = false, features = [
"deflate-flate2-zlib-rs",
-] }
-tracing-subscriber = "0.3"
-owo-colors = "4.3"
-shlex = "2.0"
-\ No newline at end of file
+] }
+\ No newline at end of file
diff --git a/crates/libeufin-nexus/src/bin/testbench.rs b/crates/libeufin-nexus/src/bin/testbench.rs
@@ -1,358 +0,0 @@
-/*
-* 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::{borrow::Cow, fmt::Display, str::FromStr};
-
-use anyhow::bail;
-use clap::{Parser, ValueEnum};
-use jiff::Timestamp;
-use libeufin_ebics::keys::{load_bank_keys, load_client_keys};
-use libeufin_nexus::{CONFIG_SOURCE, config::NexusCfg, run};
-use owo_colors::OwoColorize as _;
-use reedline::{FileBackedHistory, Prompt, Reedline, Signal};
-use taler_common::{config::Config, log::taler_logger, types::payto::TransferIbanPayto};
-use tracing::Level;
-use tracing_subscriber::util::SubscriberInitExt as _;
-
-#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
-enum Component {
- Nexus,
- Ebisync,
-}
-
-#[derive(Parser)]
-/// Run integration tests on banks provider
-pub struct TestbenchCmd {
- #[arg(value_enum)]
- component: Component,
- platform: String,
-}
-
-#[derive(Parser)]
-#[command(name = "shell", no_binary_name = true)]
-/// Run integration tests on banks provider
-pub enum NexusCmd {
- ResetKeys,
- ResetDb,
- Tx,
- Fetch {
- #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
- raw_args: Vec<String>,
- },
- Submit {
- #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
- raw_args: Vec<String>,
- },
- List {
- #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
- raw_args: Vec<String>,
- },
- Wss,
- TxCheck,
- Exit,
-}
-
-fn step(name: impl Display) {
- println!("{}", name.magenta())
-}
-
-fn msg(msg: impl Display) {
- println!("{}", msg.yellow())
-}
-
-fn err(msg: impl Display) {
- println!("{}", msg.red())
-}
-
-fn check<R, E: Display>(res: Result<R, E>) -> bool {
- match &res {
- Ok(_) => println!("{}", "OK".green()),
- Err(e) => {
- tracing::error!(target: "testbench", "{e}");
- err("ERROR")
- }
- };
- res.is_ok()
-}
-
-pub async fn nexus_cmd(cfg: &Config, cmd: &str) -> bool {
- let parts = shlex::split(cmd).unwrap();
- let args = std::iter::once("libeufin_nexus").chain(parts.iter().map(|it| it.as_str()));
-
- match libeufin_nexus::Args::try_parse_from(args) {
- Ok(cmd) => {
- tokio::select! {
- res = run(cfg.clone(), cmd.cmd) => check(res),
- _ = tokio::signal::ctrl_c() => false
- }
- }
- Err(e) => {
- println!("Error: {}", e);
- false
- }
- }
-}
-
-#[tokio::main]
-async fn main() -> anyhow::Result<()> {
- taler_logger(Some(Level::DEBUG), false).init();
- let cmd = TestbenchCmd::parse();
- // List available platform
- let platforms: Vec<_> = std::fs::read_dir("testbench/test/platform")
- .unwrap()
- .filter_map(|entry| {
- let e = entry.unwrap();
- let filename = e.file_name();
- if filename == "config.json" {
- None
- } else {
- Some(
- filename
- .to_string_lossy()
- .strip_suffix(".conf")
- .unwrap()
- .to_owned(),
- )
- }
- })
- .collect();
- if !platforms.contains(&cmd.platform) {
- bail!(
- "Unknown platform '{}', expected one of {}",
- cmd.platform,
- platforms.join(", ")
- );
- }
-
- // Augment config
- let simple_cfg =
- std::fs::read_to_string(format!("testbench/test/platform/{}.conf", cmd.platform)).unwrap();
- let cfg = 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
- "#,
- simple_cfg
- .replace("[nexus-ebics]", "[ebisync]")
- .replace("[nexus-setup]", "[ebisync-setup]"),
- cmd.platform,
- cmd.platform
- );
-
- let history = Box::new(
- FileBackedHistory::with_file(
- 1000,
- match cmd.component {
- Component::Ebisync => ".ebisync_history",
- Component::Nexus => ".nexus_history",
- }
- .into(),
- )
- .expect("Error configuring history with file"),
- );
- let mut line_editor = Reedline::create().with_history(history);
- let prompt = BenchPrompt {
- prompt: format!("{:?} {}", cmd.component, cmd.platform),
- };
- let cfg = Config::from_mem_with_env(CONFIG_SOURCE, &cfg).unwrap();
- let cfg = NexusCfg::parse(cfg).unwrap();
- let ebics = cfg.keys().unwrap();
- let (name, settings) = match cfg.host().unwrap().base_url.as_str() {
- "https://isotest.postfinance.ch/ebicsweb/ebicsweb" => (
- "PostFinance IsoTest",
- Some("https://isotest.postfinance.ch/corporates/user/settings/ebics"),
- ),
- "https://iso20022test.credit-suisse.com/ebicsweb/ebicsweb" => (
- "Credit Suisse isoTest",
- Some("https://iso20022test.credit-suisse.com/user/settings/ebics"),
- ),
- "https://ebics.postfinance.ch/ebics/ebics.aspx" => ("PostFinance", None),
- _ => ("Unknown", None),
- };
- let test = settings.is_some();
- let payto = match cfg.currency.as_ref() {
- "CHF" => {
- "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff"
- }
- "EUR" => {
- "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff"
- }
- _ => todo!("{}", cfg.currency),
- };
- let payto = TransferIbanPayto::from_str(payto).unwrap();
- let ebics_log = format!("--debug-ebics testbench/test/{}", cmd.platform);
- loop {
- // Automatic setup
- {
- let client = load_client_keys(ebics.client.as_ref()).unwrap();
- let bank = load_bank_keys(ebics.bank.as_ref()).unwrap();
- if settings.is_none() && client.is_none() {
- msg("Manual setup is required for non test environment")
- } else if client
- .map(|it| !it.submitted_ini || !it.submitted_hia)
- .unwrap_or(true)
- || bank.map(|it| !it.accepted).unwrap_or(true)
- {
- step("Run EBICS setup");
- if !nexus_cmd(&cfg.cfg, &format!("ebics-setup {ebics_log}")).await {
- if let Some(settings) = settings {
- let client = load_client_keys(ebics.client.as_ref()).unwrap();
- if client
- .map(|it| !it.submitted_ini || !it.submitted_hia)
- .unwrap_or(true)
- {
- msg(format_args!(
- "Got to {settings} and click on 'Reset EBICS user'"
- ))
- } else {
- msg(format_args!(
- "Got to {settings} and click on 'Activate EBICS user'"
- ))
- }
- } else {
- msg("Activate your keys at your bank")
- }
- }
- }
- }
- let Signal::Success(buf) = line_editor.read_line(&prompt).unwrap() else {
- break;
- };
- match NexusCmd::try_parse_from(buf.split_whitespace()) {
- Ok(cmd) => match cmd {
- NexusCmd::ResetDb => {
- nexus_cmd(&cfg.cfg, "dbinit -r").await;
- }
- NexusCmd::Fetch { raw_args } => {
- nexus_cmd(
- &cfg.cfg,
- &format!(
- "ebics-fetch {ebics_log} {}",
- shlex::try_join(raw_args.iter().map(|it| it.as_str())).unwrap()
- ),
- )
- .await;
- }
- NexusCmd::Submit { raw_args } => {
- nexus_cmd(
- &cfg.cfg,
- &format!(
- "ebics-submit {ebics_log} {}",
- shlex::try_join(raw_args.iter().map(|it| it.as_str())).unwrap()
- ),
- )
- .await;
- }
- NexusCmd::Tx => {
- nexus_cmd(
- &cfg.cfg,
- &format!(
- "initiate-payment --amount={}:0.1 --subject=\"single {}\" {payto}",
- cfg.currency,
- Timestamp::now()
- ),
- )
- .await;
- }
- NexusCmd::List { raw_args } => {
- nexus_cmd(
- &cfg.cfg,
- &format!(
- "list {}",
- shlex::try_join(raw_args.iter().map(|it| it.as_str())).unwrap()
- ),
- )
- .await;
- }
- NexusCmd::ResetKeys => {
- if test {
- std::fs::remove_file(&ebics.client)?;
- }
- std::fs::remove_file(&ebics.bank)?;
- }
- NexusCmd::TxCheck => {
- nexus_cmd(&cfg.cfg, &format!("testing tx-check {ebics_log}")).await;
- }
- NexusCmd::Wss => {
- nexus_cmd(&cfg.cfg, &format!("testing wss {ebics_log}")).await;
- }
- NexusCmd::Exit => return Ok(()),
- },
- Err(e) => {
- println!("{e}");
- }
- }
- }
- Ok(())
-}
-
-struct BenchPrompt {
- prompt: String,
-}
-
-impl Prompt for BenchPrompt {
- fn render_prompt_left(&self) -> Cow<'_, str> {
- Cow::Borrowed(&self.prompt)
- }
-
- fn render_prompt_right(&self) -> Cow<'_, str> {
- Cow::Borrowed("")
- }
-
- fn render_prompt_indicator(&self, _: reedline::PromptEditMode) -> Cow<'_, str> {
- Cow::Borrowed(">")
- }
-
- fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
- Cow::Borrowed(":")
- }
-
- fn render_prompt_history_search_indicator(
- &self,
- _: reedline::PromptHistorySearch,
- ) -> Cow<'_, str> {
- Cow::Borrowed(">")
- }
-}
diff --git a/crates/libeufin-nexus/src/config.rs b/crates/libeufin-nexus/src/config.rs
@@ -26,6 +26,7 @@ use jiff::{
use libeufin_ebics::{
config::{EbicsHostCfg, EbicsKeysCfg, EbicsSetupCfg},
dialect::Dialect,
+ keys::{BankKeys, ClientKeys},
};
use regex::Regex;
use taler_api::{
@@ -336,4 +337,8 @@ impl NexusCfg {
auth: setup.auth.as_deref(),
})
}
+
+ pub fn expect_full_keys(&self) -> anyhow::Result<(ClientKeys, BankKeys)> {
+ libeufin_ebics::keys::expect_full_keys(&self.keys()?.ebics(), "libeufin-nexus ebics-setup")
+ }
}
diff --git a/crates/libeufin-nexus/src/lib.rs b/crates/libeufin-nexus/src/lib.rs
@@ -17,19 +17,21 @@
* <http://www.gnu.org/licenses/>
*/
-use std::{str::FromStr, sync::Arc, time::Duration};
+use std::{fmt::Write, str::FromStr, sync::Arc, time::Duration};
use anyhow::{anyhow, bail};
-use compact_str::{CompactString, CompactStringExt};
+use compact_str::{CompactString, CompactStringExt, ToCompactString};
use jiff::{Timestamp, civil::Date};
use libeufin_ebics::{
cli::EbicsLogs,
ebics::{
- EbicsClient, EbicsCtx, EbicsErrKind, EbicsError, EbicsErrorHelper as _, order::Order,
+ EbicsClient, EbicsCtx, EbicsErrKind, EbicsError, EbicsErrorHelper as _,
+ administrative::{AccountInfo, HKD, OrderInfo},
+ order::Order,
rand_ebics_id,
},
iso20022::pain001::{Pain001Msg, Pain001Tx, create_pain001},
- keys::{BankKeys, ClientKeys, expect_full_keys},
+ keys::{BankKeys, ClientKeys},
};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use sqlx::PgPool;
@@ -312,6 +314,7 @@ pub async fn ebics_submit(
pub async fn ebics_setup(
ebics: &EbicsClient<'_>,
cfg: &NexusCfg,
+ db: &PgPool,
force_keys_resubmission: bool,
generate_registration_pdf: bool,
auto_accept_keys: bool,
@@ -327,7 +330,116 @@ pub async fn ebics_setup(
// Check account information
info!(target: "setup", "Doing administrative request HKD");
- // TODO HKD
+ let HKD { partner, users } = ebics.hkd(db, &client, &bank, false).await?;
+ let host = cfg.host()?;
+ let user = users.iter().find(|it| it.id == host.user_id);
+ // Debug logging
+ let fmt = std::fmt::from_fn(|f| {
+ if partner.name.is_some() || !partner.accounts.is_empty() {
+ f.write_str("Partner Info: ")?;
+ if let Some(name) = &partner.name {
+ write!(f, "'{name}'")?;
+ }
+ for AccountInfo {
+ currency,
+ iban,
+ bic,
+ } in &partner.accounts
+ {
+ write!(f, "{currency}-{iban}{bic}")?;
+ }
+ f.write_char('\n')?;
+ }
+ writeln!(f, "Supported orders:")?;
+ for OrderInfo { order, description } in &partner.orders {
+ writeln!(f, "- {order}: {description}")?;
+ }
+ if let Some(user) = user {
+ writeln!(f, "Authorized orders:")?;
+ for order in &user.permissions {
+ writeln!(f, "- {order}")?;
+ }
+ }
+ Ok(())
+ });
+ debug!(target: "setup", "{fmt}");
+
+ // Check partner info match config
+ let ebics = cfg.ebics()?;
+ if let Some(name) = &partner.name
+ && name != ebics.account.name
+ {
+ warn!(target: "setup", "Expected NAME '{}' from config got '{name}' from bank", ebics.account.name);
+ }
+ if let Some(account) = partner
+ .accounts
+ .iter()
+ .find(|it| it.iban == ebics.account.iban)
+ {
+ if account.currency != cfg.currency {
+ error!(target:"setup", "Expected CURRENCY '{}' from config got '{}' from bank", cfg.currency, account.currency);
+ }
+ if let Some(bic) = ebics.account.bic
+ && bic != account.bic
+ {
+ error!(target:"setup", "Expected BIC '{bic}' from config got '{}' from bank", account.bic);
+ }
+ } else if !partner.accounts.is_empty() {
+ let ibans = partner.accounts.iter().map(|it| it.iban).join_compact(" ");
+ error!(target: "setup", "Expected IBAN {} from config got {ibans} from bank", ebics.account.iban);
+ }
+
+ let std = ebics.dialect.standard();
+ let instant_debit_order = std.instant_direct_debit();
+ let debit_order = std.direct_debit();
+ let required_orders = std.required();
+ let partner_orders: Vec<_> = partner.orders.iter().map(|it| &it.order).collect();
+
+ // Check partner support for direct debit orders
+ if let Some(o) = &instant_debit_order
+ && !partner_orders.contains(&o)
+ {
+ warn!(target: "setup", "Unsupported instant debit order: {o}");
+ }
+ if !partner_orders.contains(&&debit_order) {
+ warn!(target: "setup", "Unsupported debit order: {debit_order}");
+ }
+
+ // Check partner support required orders
+ let unsupported = required_orders
+ .iter()
+ .filter(|o| !partner_orders.contains(o))
+ .map(|o| o.to_compact_string())
+ .join_compact(" ");
+ if !unsupported.is_empty() {
+ warn!(target: "setup", "Unsupported orders: {unsupported}")
+ }
+
+ if let Some(user) = user {
+ // Check user is authorized for direct debit orders
+ if let Some(o) = &instant_debit_order
+ && partner_orders.contains(&o)
+ && !user.permissions.contains(o)
+ {
+ warn!(target: "setup", "Unauthorized instant debit order: {o}");
+ }
+ if partner_orders.contains(&&debit_order) && !user.permissions.contains(&debit_order) {
+ warn!(target: "setup", "Unauthorized debit order: {debit_order}");
+ }
+
+ // Check user is authorized for required orders
+ let unsupported = required_orders
+ .iter()
+ .filter(|o| partner_orders.contains(o) && !user.permissions.contains(o))
+ .map(|o| o.to_compact_string())
+ .join_compact(" ");
+ if !unsupported.is_empty() {
+ warn!(target: "setup", "Unauthorized orders: {unsupported}")
+ }
+
+ // Check user is authorized for required orders
+ info!(target: "setup", "Subscriber status: {}", user.status.description())
+ }
eprintln!("setup ready");
Ok(())
@@ -344,11 +456,13 @@ pub async fn run(cfg: Config, cmd: Cmd) -> anyhow::Result<()> {
auto_accept_keys,
generate_registration_pdf,
} => {
+ let pool = pool(&cfg).await?;
let cfg = NexusCfg::parse(cfg)?;
let ebics = EbicsClient::new(cfg.host()?.ebics(), ebics_logs)?;
ebics_setup(
&ebics,
&cfg,
+ &pool,
force_keys_resubmission,
generate_registration_pdf,
auto_accept_keys,
@@ -363,9 +477,8 @@ pub async fn run(cfg: Config, cmd: Cmd) -> anyhow::Result<()> {
} => {
let pool = pool(&cfg).await?;
let cfg = NexusCfg::parse(cfg)?;
- let key_cfg = cfg.keys()?;
let ebics = EbicsClient::new(cfg.host()?.ebics(), logs)?;
- let (client, bank) = expect_full_keys(&key_cfg.ebics())?;
+ let (client, bank) = cfg.expect_full_keys()?;
ebics_fetch(
&ebics,
&cfg,
@@ -386,8 +499,7 @@ pub async fn run(cfg: Config, cmd: Cmd) -> anyhow::Result<()> {
let pool = pool(&cfg).await?;
let cfg = NexusCfg::parse(cfg)?;
let ebics = EbicsClient::new(cfg.host()?.ebics(), logs)?;
- let key_cfg = cfg.keys()?;
- let (client, bank) = expect_full_keys(&key_cfg.ebics())?;
+ let (client, bank) = cfg.expect_full_keys()?;
ebics_submit(&ebics, &cfg, &client, &bank, &pool, transient).await?
}
Cmd::InitiatePayment {
diff --git a/crates/libeufin-nexus/src/test.rs b/crates/libeufin-nexus/src/test.rs
@@ -336,6 +336,8 @@ mod ebics {
EbicsState::ini,
EbicsState::hia,
EbicsState::hpb,
+ EbicsState::hkd,
+ EbicsState::receipt_ok,
]);
nexus_cmd(&cfg, "ebics-setup --auto-accept-keys")
.await
diff --git a/crates/libeufin-nexus/src/testing.rs b/crates/libeufin-nexus/src/testing.rs
@@ -27,7 +27,6 @@ use libeufin_ebics::{
tx_check,
},
iso20022::model::{InId, InTx},
- keys::expect_full_keys,
ws::listen_for_notification,
};
use taler_common::{
@@ -202,7 +201,7 @@ impl TestingCmd {
}),
)
.ok_or(anyhow!("Unknown ebics order"))?;
- let (client, bank) = expect_full_keys(&cfg.keys()?.ebics())?;
+ let (client, bank) = cfg.expect_full_keys()?;
let ebics = EbicsClient::new(cfg.host()?.ebics(), logs)?;
ebics
.download(
@@ -230,7 +229,7 @@ impl TestingCmd {
TestingCmd::TxCheck { logs } => {
let db = pool(&cfg).await?;
let cfg = NexusCfg::parse(cfg)?;
- let (client, bank) = expect_full_keys(&cfg.keys()?.ebics())?;
+ let (client, bank) = cfg.expect_full_keys()?;
let ebics = EbicsClient::new(cfg.host()?.ebics(), logs)?;
let dialect = cfg.ebics()?.dialect.standard();
let res = tx_check(
@@ -247,7 +246,7 @@ impl TestingCmd {
TestingCmd::Wss { logs } => {
let db = pool(&cfg).await?;
let cfg = NexusCfg::parse(cfg)?;
- let (client, bank) = expect_full_keys(&cfg.keys()?.ebics())?;
+ let (client, bank) = cfg.expect_full_keys()?;
let ebics = EbicsClient::new(cfg.host()?.ebics(), logs)?;
let (sender, mut receiver) = tokio::sync::mpsc::channel(10);
tokio::join!(
diff --git a/crates/testbench/Cargo.toml b/crates/testbench/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "testbench"
+version.workspace = true
+edition.workspace = true
+authors.workspace = true
+homepage.workspace = true
+repository.workspace = true
+license-file.workspace = true
+
+[dependencies]
+tokio.workspace = true
+tracing.workspace = true
+anyhow.workspace = true
+jiff.workspace = true
+clap.workspace = true
+taler-common.workspace = true
+libeufin-nexus = { path = "../libeufin-nexus"}
+libeufin-ebics = { path = "../libeufin-ebics"}
+reedline = "0.47"
+shlex = "2.0"
+owo-colors = "4.3"
+tracing-subscriber = "0.3"
+\ No newline at end of file
diff --git a/crates/testbench/src/main.rs b/crates/testbench/src/main.rs
@@ -0,0 +1,500 @@
+/*
+* 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::{borrow::Cow, fmt::Display, str::FromStr};
+
+use anyhow::bail;
+use clap::{Parser, ValueEnum};
+use jiff::Timestamp;
+use libeufin_ebics::keys::{load_bank_keys, load_client_keys};
+use libeufin_nexus::{CONFIG_SOURCE, config::NexusCfg, run};
+use owo_colors::OwoColorize as _;
+use reedline::{FileBackedHistory, Prompt, Reedline, Signal};
+use taler_common::{config::Config, log::taler_logger, types::payto::TransferIbanPayto};
+use tracing::Level;
+use tracing_subscriber::util::SubscriberInitExt as _;
+
+#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
+enum Component {
+ Nexus,
+ Ebisync,
+}
+
+#[derive(Parser)]
+/// Run integration tests on banks provider
+pub struct TestbenchCmd {
+ #[arg(value_enum)]
+ component: Component,
+ platform: String,
+}
+
+#[derive(Parser)]
+#[command(name = "shell", no_binary_name = true)]
+/// Run integration tests on banks provider
+pub enum NexusCmd {
+ #[clap(alias = "ebics-setup")]
+ Setup,
+ ResetKeys,
+ ResetDb,
+ Tx,
+ Fetch {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ raw_args: Vec<String>,
+ },
+ Submit {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ raw_args: Vec<String>,
+ },
+ List {
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ raw_args: Vec<String>,
+ },
+ Wss,
+ TxCheck,
+ Exit,
+}
+
+fn step(name: impl Display) {
+ println!("{}", name.magenta())
+}
+
+fn msg(msg: impl Display) {
+ println!("{}", msg.yellow())
+}
+
+fn err(msg: impl Display) {
+ println!("{}", msg.red())
+}
+
+fn check<R, E: Display>(res: Result<R, E>) -> bool {
+ match &res {
+ Ok(_) => println!("{}", "OK".green()),
+ Err(e) => {
+ tracing::error!(target: "testbench", "{e}");
+ err("ERROR")
+ }
+ };
+ res.is_ok()
+}
+
+pub async fn nexus_cmd(cfg: &Config, cmd: &str) -> bool {
+ let parts = shlex::split(cmd).unwrap();
+ let args = std::iter::once("libeufin_nexus").chain(parts.iter().map(|it| it.as_str()));
+
+ match libeufin_nexus::Args::try_parse_from(args) {
+ Ok(cmd) => {
+ tokio::select! {
+ res = run(cfg.clone(), cmd.cmd) => check(res),
+ _ = tokio::signal::ctrl_c() => false
+ }
+ }
+ Err(e) => {
+ println!("Error: {}", e);
+ false
+ }
+ }
+}
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ taler_logger(Some(Level::DEBUG), false).init();
+ let cmd = TestbenchCmd::parse();
+ // List available platform
+ let platforms: Vec<_> = std::fs::read_dir("testbench/test/platform")
+ .unwrap()
+ .filter_map(|entry| {
+ let e = entry.unwrap();
+ let filename = e.file_name();
+ if filename == "config.json" {
+ None
+ } else {
+ Some(
+ filename
+ .to_string_lossy()
+ .strip_suffix(".conf")
+ .unwrap()
+ .to_owned(),
+ )
+ }
+ })
+ .collect();
+ if !platforms.contains(&cmd.platform) {
+ bail!(
+ "Unknown platform '{}', expected one of {}",
+ cmd.platform,
+ platforms.join(", ")
+ );
+ }
+
+ // Augment config
+ let simple_cfg =
+ std::fs::read_to_string(format!("testbench/test/platform/{}.conf", cmd.platform)).unwrap();
+ let cfg = 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
+ "#,
+ simple_cfg
+ .replace("[nexus-ebics]", "[ebisync]")
+ .replace("[nexus-setup]", "[ebisync-setup]"),
+ cmd.platform,
+ cmd.platform
+ );
+
+ let history = Box::new(
+ FileBackedHistory::with_file(
+ 1000,
+ match cmd.component {
+ Component::Ebisync => ".ebisync_history",
+ Component::Nexus => ".nexus_history",
+ }
+ .into(),
+ )
+ .expect("Error configuring history with file"),
+ );
+ let cfg = Config::from_mem_with_env(CONFIG_SOURCE, &cfg).unwrap();
+ let cfg = NexusCfg::parse(cfg).unwrap();
+ let ebics = cfg.keys().unwrap();
+ let (name, settings) = match cfg.host().unwrap().base_url.as_str() {
+ "https://isotest.postfinance.ch/ebicsweb/ebicsweb" => (
+ Some("PostFinance IsoTest"),
+ Some("https://isotest.postfinance.ch/corporates/user/settings/ebics"),
+ ),
+ "https://iso20022test.credit-suisse.com/ebicsweb/ebicsweb" => (
+ Some("Credit Suisse isoTest"),
+ Some("https://iso20022test.credit-suisse.com/user/settings/ebics"),
+ ),
+ "https://ebics.postfinance.ch/ebics/ebics.aspx" => (Some("PostFinance"), None),
+ _ => (None, None),
+ };
+ let test = settings.is_some();
+ let payto = match cfg.currency.as_ref() {
+ "CHF" => {
+ "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff"
+ }
+ "EUR" => {
+ "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff"
+ }
+ _ => todo!("{}", cfg.currency),
+ };
+ let payto = TransferIbanPayto::from_str(payto).unwrap();
+ let ebics_log = format!("--debug-ebics testbench/test/{}", cmd.platform);
+
+ let mut line_editor = Reedline::create().with_history(history);
+ let prompt = BenchPrompt {
+ prompt: format!(
+ "{:?} {}{}",
+ cmd.component,
+ cmd.platform,
+ std::fmt::from_fn(|f| {
+ if let Some(name) = &name {
+ write!(f, " {name}")
+ } else {
+ Ok(())
+ }
+ })
+ ),
+ };
+ loop {
+ // Automatic setup
+ {
+ let client = load_client_keys(ebics.client.as_ref()).unwrap();
+ let bank = load_bank_keys(ebics.bank.as_ref()).unwrap();
+ if settings.is_none() && client.is_none() {
+ msg("Manual setup is required for non test environment")
+ } else if client
+ .map(|it| !it.submitted_ini || !it.submitted_hia)
+ .unwrap_or(true)
+ || bank.map(|it| !it.accepted).unwrap_or(true)
+ {
+ step("Run EBICS setup");
+ if !nexus_cmd(&cfg.cfg, &format!("ebics-setup {ebics_log}")).await {
+ if let Some(settings) = settings {
+ let client = load_client_keys(ebics.client.as_ref()).unwrap();
+ if client
+ .map(|it| !it.submitted_ini || !it.submitted_hia)
+ .unwrap_or(true)
+ {
+ msg(format_args!(
+ "Got to {settings} and click on 'Reset EBICS user'"
+ ))
+ } else {
+ msg(format_args!(
+ "Got to {settings} and click on 'Activate EBICS user'"
+ ))
+ }
+ } else {
+ msg("Activate your keys at your bank")
+ }
+ }
+ }
+ }
+ let Signal::Success(buf) = line_editor.read_line(&prompt).unwrap() else {
+ break;
+ };
+ match NexusCmd::try_parse_from(buf.split_whitespace()) {
+ Ok(cmd) => match cmd {
+ NexusCmd::Setup => {
+ nexus_cmd(&cfg.cfg, &format!("ebics-setup {ebics_log}")).await;
+ }
+ NexusCmd::ResetDb => {
+ nexus_cmd(&cfg.cfg, "dbinit -r").await;
+ }
+ NexusCmd::Fetch { raw_args } => {
+ nexus_cmd(
+ &cfg.cfg,
+ &format!(
+ "ebics-fetch {ebics_log} {}",
+ shlex::try_join(raw_args.iter().map(|it| it.as_str())).unwrap()
+ ),
+ )
+ .await;
+ }
+ NexusCmd::Submit { raw_args } => {
+ nexus_cmd(
+ &cfg.cfg,
+ &format!(
+ "ebics-submit {ebics_log} {}",
+ shlex::try_join(raw_args.iter().map(|it| it.as_str())).unwrap()
+ ),
+ )
+ .await;
+ }
+ NexusCmd::Tx => {
+ nexus_cmd(
+ &cfg.cfg,
+ &format!(
+ "initiate-payment --amount={}:0.1 --subject=\"single {}\" {payto}",
+ cfg.currency,
+ Timestamp::now()
+ ),
+ )
+ .await;
+ }
+ NexusCmd::List { raw_args } => {
+ nexus_cmd(
+ &cfg.cfg,
+ &format!(
+ "list {}",
+ shlex::try_join(raw_args.iter().map(|it| it.as_str())).unwrap()
+ ),
+ )
+ .await;
+ }
+ NexusCmd::ResetKeys => {
+ if test {
+ std::fs::remove_file(&ebics.client)?;
+ }
+ std::fs::remove_file(&ebics.bank)?;
+ }
+ NexusCmd::TxCheck => {
+ nexus_cmd(&cfg.cfg, &format!("testing tx-check {ebics_log}")).await;
+ }
+ NexusCmd::Wss => {
+ nexus_cmd(&cfg.cfg, &format!("testing wss {ebics_log}")).await;
+ }
+ NexusCmd::Exit => return Ok(()),
+ },
+ Err(e) => {
+ println!("{e}");
+ }
+ }
+ }
+ Ok(())
+}
+
+struct BenchPrompt {
+ prompt: String,
+}
+
+impl Prompt for BenchPrompt {
+ fn render_prompt_left(&self) -> Cow<'_, str> {
+ Cow::Borrowed(&self.prompt)
+ }
+
+ fn render_prompt_right(&self) -> Cow<'_, str> {
+ Cow::Borrowed("")
+ }
+
+ fn render_prompt_indicator(&self, _: reedline::PromptEditMode) -> Cow<'_, str> {
+ Cow::Borrowed(">")
+ }
+
+ fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
+ Cow::Borrowed(":")
+ }
+
+ fn render_prompt_history_search_indicator(
+ &self,
+ _: reedline::PromptHistorySearch,
+ ) -> Cow<'_, str> {
+ Cow::Borrowed(">")
+ }
+}
+
+#[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::{
+ Args, CONFIG_SOURCE,
+ config::{NexusCfg, NexusKeysCfg},
+ run,
+ };
+ use taler_common::config::Config;
+
+ pub fn clap_parse<T: clap::Parser>(cmd: &str) -> T {
+ let parts = shlex::split(cmd).unwrap();
+ let args = std::iter::once("bin").chain(parts.iter().map(|it| it.as_str()));
+
+ T::try_parse_from(args).unwrap()
+ }
+
+ /** 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 conf = "../../testbench/conf/cli.conf";
+ let cfg = Config::from_file(CONFIG_SOURCE, Some(conf)).unwrap();
+ 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 check_cmds = async |msg: &str| {
+ for cmd in nexus_cmds {
+ let tmp: Args = clap_parse(cmd);
+ let res = run(cfg.cfg.clone(), tmp.cmd).await.unwrap_err();
+ assert_eq!(
+ res.to_string(),
+ msg.replace("SETUPCMD", "libeufin-nexus ebics-setup")
+ );
+ }
+ };
+ let check_all_cmds = async |msg: &str| {
+ for cmd in nexus_all_cmds {
+ let tmp: Args = clap_parse(cmd);
+ let res = run(cfg.cfg.clone(), tmp.cmd).await.unwrap_err();
+ assert_eq!(
+ res.to_string(),
+ msg.replace("SETUPCMD", "libeufin-nexus ebics-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;
+ // 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;
+ // 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;
+ }
+}