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 }