libeufin

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

commit 2d74f521631a6cf1c4f4797b7c46767e8b0f1d5c
parent 2e94ff95b271fa0c530d4e1ca0d5efa3878eea34
Author: Antoine A <>
Date:   Wed, 27 May 2026 17:13:53 +0200

nexus: finish setup

Diffstat:
MCargo.lock | 35+++++++++++++++++++++++++----------
Mcrates/libeufin-ebics/src/dialect.rs | 16++++++++--------
Mcrates/libeufin-ebics/src/keys.rs | 24++++++++++++++++--------
Mcrates/libeufin-ebics/src/pdf.rs | 5++---
Mcrates/libeufin-ebics/src/setup.rs | 6+++++-
Mcrates/libeufin-ebics/src/test.rs | 4+++-
Mcrates/libeufin-nexus/Cargo.toml | 9+++------
Dcrates/libeufin-nexus/src/bin/testbench.rs | 358-------------------------------------------------------------------------------
Mcrates/libeufin-nexus/src/config.rs | 5+++++
Mcrates/libeufin-nexus/src/lib.rs | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/libeufin-nexus/src/test.rs | 2++
Mcrates/libeufin-nexus/src/testing.rs | 7+++----
Acrates/testbench/Cargo.toml | 23+++++++++++++++++++++++
Acrates/testbench/src/main.rs | 500+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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; + } +}