libeufin

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

main.rs (17426B)


      1 /*
      2 * This file is part of LibEuFin.
      3 * Copyright (C) 2026 Taler Systems S.A.
      4 
      5 * LibEuFin is free software; you can redistribute it and/or modify
      6 * it under the terms of the GNU Affero General Public License as
      7 * published by the Free Software Foundation; either version 3, or
      8 * (at your option) any later version.
      9 
     10 * LibEuFin is distributed in the hope that it will be useful, but
     11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13 * Public License for more details.
     14 
     15 * You should have received a copy of the GNU Affero General Public
     16 * License along with LibEuFin; see the file COPYING.  If not, see
     17 * <http://www.gnu.org/licenses/>
     18 */
     19 
     20 use std::{
     21     collections::HashSet,
     22     fmt::Display,
     23     path::{Path, PathBuf},
     24     str::FromStr,
     25 };
     26 
     27 use anyhow::bail;
     28 use aws_lc_rs::digest::SHA256;
     29 use clap::{Parser, ValueEnum};
     30 use globset::{Glob, GlobSetBuilder};
     31 use jiff::Timestamp;
     32 use libeufin_ebics::keys::{load_bank_keys, load_client_keys};
     33 use libeufin_nexus::config::NexusCfg;
     34 use owo_colors::OwoColorize as _;
     35 use taler_common::{config::Config, log::taler_logger, types::payto::TransferIbanPayto};
     36 use taler_test_utils::{cli::clap_parse, repl::Repl};
     37 use tracing::Level;
     38 use tracing_subscriber::util::SubscriberInitExt as _;
     39 use walkdir::WalkDir;
     40 
     41 #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
     42 enum Component {
     43     Nexus,
     44     Ebisync,
     45 }
     46 
     47 impl Component {
     48     pub async fn run(&self, cfg: &Config, cmd: &str) -> bool {
     49         match self {
     50             Component::Nexus => match clap_parse::<libeufin_nexus::Args>(cmd) {
     51                 Ok(cmd) => {
     52                     tokio::select! {
     53                         res = libeufin_nexus::run(cfg, cmd.cmd) => check(res),
     54                         _ = tokio::signal::ctrl_c() => false
     55                     }
     56                 }
     57                 Err(e) => {
     58                     println!("Error: {}", e);
     59                     false
     60                 }
     61             },
     62             Component::Ebisync => match clap_parse::<libeufin_ebisync::Args>(cmd) {
     63                 Ok(cmd) => {
     64                     tokio::select! {
     65                         res = libeufin_ebisync::run(cfg, cmd.cmd) => check(res),
     66                         _ = tokio::signal::ctrl_c() => false
     67                     }
     68                 }
     69                 Err(e) => {
     70                     println!("Error: {}", e);
     71                     false
     72                 }
     73             },
     74         }
     75     }
     76 }
     77 
     78 #[derive(Parser)]
     79 /// Run integration tests on banks provider
     80 pub struct TestbenchCmd {
     81     #[arg(value_enum)]
     82     component: Component,
     83     platform: String,
     84 }
     85 
     86 #[derive(Parser)]
     87 #[command(no_binary_name = true)]
     88 /// Run integration tests on banks provider
     89 pub enum NexusCmd {
     90     #[clap(alias = "ebics-setup")]
     91     Setup,
     92     ResetKeys,
     93     ResetDb,
     94     Exit,
     95     Tx,
     96     Fetch {
     97         #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
     98         raw_args: Vec<String>,
     99     },
    100     Submit {
    101         #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
    102         raw_args: Vec<String>,
    103     },
    104     List {
    105         #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
    106         raw_args: Vec<String>,
    107     },
    108     Wss,
    109     TxCheck,
    110     Import {
    111         path: PathBuf,
    112         glob: Vec<Glob>,
    113     },
    114     Export {
    115         path: PathBuf,
    116     },
    117     Compact,
    118 }
    119 
    120 #[derive(Parser)]
    121 #[command(no_binary_name = true)]
    122 /// Run integration tests on banks provider
    123 pub enum SyncCmd {
    124     #[clap(alias = "ebics-setup")]
    125     Setup,
    126     ResetKeys,
    127     ResetDb,
    128     Exit,
    129     Fetch {
    130         #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
    131         raw_args: Vec<String>,
    132     },
    133     Testing {
    134         #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
    135         raw_args: Vec<String>,
    136     },
    137     Serve,
    138     Compact,
    139 }
    140 
    141 fn step(name: impl Display) {
    142     println!("{}", name.magenta())
    143 }
    144 
    145 fn msg(msg: impl Display) {
    146     println!("{}", msg.yellow())
    147 }
    148 
    149 fn err(msg: impl Display) {
    150     println!("{}", msg.red())
    151 }
    152 
    153 fn check<R, E: Display>(res: Result<R, E>) -> bool {
    154     match &res {
    155         Ok(_) => println!("{}", "OK".green()),
    156         Err(e) => {
    157             tracing::error!(target: "testbench", "{e}");
    158             err("ERROR")
    159         }
    160     };
    161     res.is_ok()
    162 }
    163 
    164 #[tokio::main]
    165 async fn main() -> anyhow::Result<()> {
    166     taler_logger(Some(Level::DEBUG), false).init();
    167     let cmd = TestbenchCmd::parse();
    168 
    169     // List available platform
    170     let platforms: Vec<_> = std::fs::read_dir("testbench/test/platform")
    171         .unwrap()
    172         .filter_map(|entry| {
    173             let e = entry.unwrap();
    174             let filename = e.file_name();
    175             if filename == "config.json" {
    176                 None
    177             } else {
    178                 Some(
    179                     filename
    180                         .to_string_lossy()
    181                         .strip_suffix(".conf")
    182                         .unwrap()
    183                         .to_owned(),
    184                 )
    185             }
    186         })
    187         .collect();
    188     if !platforms.contains(&cmd.platform) {
    189         bail!(
    190             "Unknown platform '{}', expected one of {}",
    191             cmd.platform,
    192             platforms.join(", ")
    193         );
    194     }
    195 
    196     // Augment config
    197     let simple_cfg =
    198         std::fs::read_to_string(format!("testbench/test/platform/{}.conf", cmd.platform)).unwrap();
    199     let cfg = Config::from_mem_with_env(
    200         match cmd.component {
    201             Component::Nexus => libeufin_nexus::constants::CONFIG_SOURCE,
    202             Component::Ebisync => libeufin_ebisync::constants::CONFIG_SOURCE
    203         }
    204         , &format!(
    205             r#"
    206         {simple_cfg}
    207         {}
    208         [paths]
    209         LIBEUFIN_NEXUS_HOME = testbench/test/{}
    210         EBISYNC_HOME = testbench/test/{}
    211 
    212         [nexus-fetch]
    213         FREQUENCY = 1h
    214         CHECKPOINT_TIME_OF_DAY = 16:52
    215 
    216         [ebisync-fetch]
    217         FREQUENCY = 1h
    218         CHECKPOINT_TIME_OF_DAY = 16:52
    219         DESTINATION = azure-blob-storage
    220         AZURE_API_URL = http://localhost:10000/devstoreaccount1/
    221         AZURE_ACCOUNT_NAME = devstoreaccount1
    222         AZURE_ACCOUNT_KEY = Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==
    223         AZURE_CONTAINER = test
    224 
    225         [ebisync-submit]
    226         SOURCE = ebisync-api
    227         AUTH_METHOD = none
    228 
    229         [libeufin-nexusdb-postgres]
    230         CONFIG = postgres:///libeufintestbench
    231 
    232         [ebisyncdb-postgres]
    233         CONFIG = postgres:///libeufintestbench
    234     "#,
    235             simple_cfg
    236                 .replace("[nexus-ebics]", "[ebisync]")
    237                 .replace("[nexus-setup]", "[ebisync-setup]"),
    238             cmd.platform,
    239             cmd.platform
    240         )).unwrap();
    241     let cfg = NexusCfg::parse(&cfg).unwrap();
    242     let ebics = cfg.keys().unwrap();
    243     let (name, settings) = match cfg.host().unwrap().base_url.as_str() {
    244         "https://isotest.postfinance.ch/ebicsweb/ebicsweb" => (
    245             "PostFinance IsoTest",
    246             Some("https://isotest.postfinance.ch/corporates/user/settings/ebics"),
    247         ),
    248         "https://iso20022test.credit-suisse.com/ebicsweb/ebicsweb" => (
    249             "Credit Suisse isoTest",
    250             Some("https://iso20022test.credit-suisse.com/user/settings/ebics"),
    251         ),
    252         "https://ebics.postfinance.ch/ebics/ebics.aspx" => ("PostFinance", None),
    253         _ => ("Unknown", None),
    254     };
    255     let test = settings.is_some();
    256     let payto = match cfg.currency.as_ref() {
    257         "CHF" => {
    258             "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff"
    259         }
    260         "EUR" => {
    261             "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff"
    262         }
    263         _ => todo!("{}", cfg.currency),
    264     };
    265     let payto = TransferIbanPayto::from_str(payto).unwrap();
    266     let ebics_log = format!("--debug-ebics testbench/test/{}", cmd.platform);
    267     let run = async |args: &str| cmd.component.run(&cfg.cfg, args).await;
    268     let auto_setup = async || {
    269         let client = load_client_keys(ebics.client.as_ref()).unwrap();
    270         let bank = load_bank_keys(ebics.bank.as_ref()).unwrap();
    271         if settings.is_none() && client.is_none() {
    272             msg("Manual setup is required for non test environment")
    273         } else if client
    274             .map(|it| !it.submitted_ini || !it.submitted_hia)
    275             .unwrap_or(true)
    276             || bank.map(|it| !it.accepted).unwrap_or(true)
    277         {
    278             step("Run EBICS setup");
    279             if !run(&format!("ebics-setup {ebics_log}")).await {
    280                 if let Some(settings) = settings {
    281                     let client = load_client_keys(ebics.client.as_ref()).unwrap();
    282                     if client
    283                         .map(|it| !it.submitted_ini || !it.submitted_hia)
    284                         .unwrap_or(true)
    285                     {
    286                         msg(format_args!(
    287                             "Got to {settings} and click on 'Reset EBICS user'"
    288                         ))
    289                     } else {
    290                         msg(format_args!(
    291                             "Got to {settings} and click on 'Activate EBICS user'"
    292                         ))
    293                     }
    294                 } else {
    295                     msg("Activate your keys at your bank")
    296                 }
    297             }
    298         }
    299     };
    300     msg(format!("Init {name}"));
    301     run("dbinit").await;
    302 
    303     let platform = cmd.platform;
    304     let prompt = format!("{:?} {platform} {name}", cmd.component);
    305 
    306     match cmd.component {
    307         Component::Nexus => {
    308             let mut repl = Repl::new(".nexus_history");
    309             auto_setup().await;
    310             while let Some(cmd) = repl.read_line(&prompt, "") {
    311                 match cmd {
    312                     NexusCmd::Setup => {
    313                         run(&format!("ebics-setup {ebics_log}")).await;
    314                     }
    315                     NexusCmd::ResetDb => {
    316                         run("dbinit -r").await;
    317                     }
    318                     NexusCmd::Compact => {
    319                         compact(format!("testbench/test/{platform}"));
    320                     }
    321                     NexusCmd::Fetch { raw_args } => {
    322                         run(&format!("ebics-fetch {ebics_log} {}", raw_args.join(" "))).await;
    323                     }
    324                     NexusCmd::Submit { raw_args } => {
    325                         run(&format!("ebics-submit {ebics_log} {}", raw_args.join(" "))).await;
    326                     }
    327                     NexusCmd::Tx => {
    328                         run(&format!(
    329                             "initiate-payment --amount={}:0.1 --subject=\"single {}\" {payto}",
    330                             cfg.currency,
    331                             Timestamp::now()
    332                         ))
    333                         .await;
    334                     }
    335                     NexusCmd::List { raw_args } => {
    336                         run(&format!("list {}", raw_args.join(" "))).await;
    337                     }
    338                     NexusCmd::ResetKeys => {
    339                         if test && std::fs::exists(&ebics.client).unwrap() {
    340                             std::fs::remove_file(&ebics.client).unwrap();
    341                         }
    342                         if std::fs::exists(&ebics.bank).unwrap() {
    343                             std::fs::remove_file(&ebics.bank).unwrap();
    344                         }
    345                     }
    346                     NexusCmd::TxCheck => {
    347                         run(&format!("testing tx-check {ebics_log}")).await;
    348                     }
    349                     NexusCmd::Wss => {
    350                         run(&format!("testing wss {ebics_log}")).await;
    351                     }
    352                     NexusCmd::Import { path, glob } => {
    353                         let mut b = GlobSetBuilder::new();
    354                         for glob in glob {
    355                             b.add(glob);
    356                         }
    357                         let matcher = b.build().unwrap();
    358                         let mut paths = Vec::new();
    359                         for entry in WalkDir::new(&path).into_iter().filter_map(Result::ok) {
    360                             let entry = entry.path();
    361 
    362                             // usually match against relative path
    363                             let rel = entry.strip_prefix(&path).unwrap_or(entry);
    364 
    365                             if matcher.is_match(rel) {
    366                                 paths.push(entry.to_string_lossy().to_string());
    367                             }
    368                         }
    369 
    370                         run(&format!("manual import {}", paths.join(" "))).await;
    371                     }
    372                     NexusCmd::Export { path } => {
    373                         run(&format!("manual export {}", path.to_string_lossy())).await;
    374                     }
    375                     NexusCmd::Exit => break,
    376                 }
    377             }
    378         }
    379         Component::Ebisync => {
    380             let mut repl = Repl::new(".ebisync_history");
    381             auto_setup().await;
    382             while let Some(cmd) = repl.read_line(&prompt, "") {
    383                 match cmd {
    384                     SyncCmd::Compact => {
    385                         compact(format!("testbench/test/{platform}"));
    386                     }
    387                     SyncCmd::Setup => {
    388                         run(&format!("setup {ebics_log}")).await;
    389                     }
    390                     SyncCmd::ResetDb => {
    391                         run("dbinit -r").await;
    392                     }
    393                     SyncCmd::Fetch { raw_args } => {
    394                         run(&format!("fetch {ebics_log} {}", raw_args.join(" "))).await;
    395                     }
    396                     SyncCmd::Testing { raw_args } => {
    397                         run(&format!("testing {}", raw_args.join(" "))).await;
    398                     }
    399                     SyncCmd::Serve => {
    400                         run("serve").await;
    401                     }
    402                     SyncCmd::ResetKeys => {
    403                         if test {
    404                             std::fs::remove_file(&ebics.client).unwrap();
    405                         }
    406                         std::fs::remove_file(&ebics.bank).unwrap();
    407                     }
    408                     SyncCmd::Exit => break,
    409                 }
    410             }
    411         }
    412     }
    413     Ok(())
    414 }
    415 
    416 /// Remove duplicated files and empty dir
    417 fn compact(p: String) {
    418     /// Announce and recursively remove file or directory
    419     fn remove(p: &Path, reason: &str) {
    420         println!("rm {} {}", reason, p.display());
    421         if p.is_dir() {
    422             std::fs::remove_dir_all(p).unwrap();
    423         } else {
    424             std::fs::remove_file(p).unwrap()
    425         }
    426     }
    427 
    428     /// Collect directory entries into a Vec upfront
    429     /// and avoids surprises from mutating a directory while iterating it).
    430     fn read_dir_paths(dir: &Path) -> Vec<PathBuf> {
    431         let mut v = Vec::new();
    432         for entry in std::fs::read_dir(dir).unwrap() {
    433             v.push(entry.unwrap().path());
    434         }
    435         v
    436     }
    437 
    438     fn is_dir_empty(dir: &Path) -> bool {
    439         std::fs::read_dir(dir).unwrap().next().is_none()
    440     }
    441 
    442     /// Delete file if another file has the same (whitespace-stripped) content
    443     fn rm_if_similar(p: &Path, content: &str, content_hashes: &mut HashSet<Vec<u8>>) {
    444         let normalized: String = content.chars().filter(|c| c.is_whitespace()).collect();
    445 
    446         let digest = aws_lc_rs::digest::digest(&SHA256, normalized.as_bytes());
    447 
    448         if content_hashes.contains(digest.as_ref()) {
    449             remove(p, "similar");
    450         } else {
    451             content_hashes.insert(digest.as_ref().to_vec());
    452         }
    453     }
    454 
    455     fn file_name_str(p: &Path) -> String {
    456         p.file_name()
    457             .map(|n| n.to_string_lossy().into_owned())
    458             .unwrap_or_default()
    459     }
    460 
    461     let mut content_hashes: HashSet<Vec<u8>> = HashSet::new();
    462 
    463     for date in read_dir_paths(p.as_ref()) {
    464         if !date.is_dir() {
    465             continue;
    466         }
    467 
    468         for request in read_dir_paths(&date) {
    469             let payload_file_path = request.join("payload.xml");
    470             let payload_dir_path = request.join("payload");
    471             let request_name = file_name_str(&request);
    472 
    473             if payload_file_path.exists() {
    474                 let content = std::fs::read_to_string(&payload_file_path).unwrap();
    475 
    476                 if request_name.contains("HAC") && !content.contains("ORDER_HAC_FINAL_NEG") {
    477                     remove(&request, "simple hac");
    478                 } else if request_name.contains("HAA") && !content.contains("<Service>") {
    479                     remove(&request, "empty haa");
    480                 } else if request_name.contains("wssparam") {
    481                     remove(&request, "wssparam");
    482                 } else {
    483                     rm_if_similar(&payload_file_path, &content, &mut content_hashes);
    484                 }
    485             } else if payload_dir_path.exists() {
    486                 for file in read_dir_paths(&payload_dir_path) {
    487                     let content = std::fs::read_to_string(&file).unwrap();
    488                     rm_if_similar(&file, &content, &mut content_hashes);
    489                 }
    490                 if is_dir_empty(&payload_dir_path) {
    491                     remove(&request, "empty payload");
    492                 }
    493             }
    494             if request.exists() && !payload_file_path.exists() && !payload_dir_path.exists() {
    495                 remove(&request, "empty request");
    496             }
    497         }
    498 
    499         if is_dir_empty(&date) {
    500             remove(&date, "empty dir");
    501         }
    502     }
    503 }