Main.kt (17337B)
1 /* 2 * This file is part of LibEuFin. 3 * Copyright (C) 2023-2025 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 package tech.libeufin.testbench 21 22 import com.github.ajalt.clikt.core.CliktCommand 23 import com.github.ajalt.clikt.core.Context 24 import com.github.ajalt.clikt.core.ProgramResult 25 import com.github.ajalt.clikt.core.main 26 import com.github.ajalt.clikt.parameters.arguments.argument 27 import com.github.ajalt.clikt.parameters.types.enum 28 import com.github.ajalt.clikt.testing.* 29 import io.ktor.client.* 30 import io.ktor.client.engine.cio.* 31 import io.ktor.http.* 32 import kotlinx.coroutines.* 33 import kotlinx.serialization.Serializable 34 import tech.libeufin.common.* 35 import tech.libeufin.nexus.* 36 import tech.libeufin.nexus.cli.LibeufinNexus 37 import tech.libeufin.ebisync.cli.LibeufinEbisync 38 import tech.libeufin.ebics.* 39 import java.time.Instant 40 import kotlin.io.path.* 41 import org.jline.terminal.* 42 import org.jline.reader.* 43 import org.jline.reader.impl.history.* 44 45 enum class Component { Nexus, Ebisync } 46 47 val nexusCmd = LibeufinNexus() 48 val ebisyncCmd = LibeufinEbisync() 49 50 val client = HttpClient(CIO) 51 var thread: Thread? = null 52 var deferred: CompletableDeferred<CliktCommandTestResult> = CompletableDeferred() 53 54 class Interrupt: Exception("Interrupt") 55 56 fun step(name: String) { 57 println(ANSI.magenta(name)) 58 } 59 60 fun msg(msg: String) { 61 println(ANSI.yellow(msg)) 62 } 63 64 fun err(msg: String) { 65 println(ANSI.red(msg)) 66 } 67 68 suspend fun CliktCommand.run(arg: String): Boolean { 69 deferred = CompletableDeferred() 70 val task = kotlin.concurrent.thread { 71 deferred.complete(this@run.test(arg)) 72 } 73 thread = task 74 task.join() 75 thread = null 76 val res = deferred.await() 77 print(res.output) 78 val success = res.statusCode == 0 79 if (success) { 80 println(ANSI.green("OK")) 81 } else { 82 err("ERROR ${res.statusCode}") 83 } 84 return success 85 } 86 87 data class Kind(val name: String, val settings: String?) { 88 val test get() = settings != null 89 } 90 91 @Serializable 92 data class Config( 93 val payto: Map<String, String> 94 ) 95 96 private val WORDS_REGEX = Regex("\\s+") 97 98 class Cli : CliktCommand() { 99 override fun help(context: Context) = "Run integration tests on banks provider" 100 101 val component by argument().enum<Component>() 102 val platform by argument() 103 104 override fun run() { 105 // List available platform 106 val platforms = Path("test/platform").listDirectoryEntries().mapNotNull { 107 val fileName = it.fileName.toString() 108 if (fileName == "config.json") { 109 null 110 } else { 111 fileName.removeSuffix(".conf") 112 } 113 } 114 if (!platforms.contains(platform)) { 115 println("Unknown platform '$platform', expected one of $platforms") 116 throw ProgramResult(1) 117 } 118 119 // Augment config 120 val simpleCfg = Path("test/platform/$platform.conf").readText() 121 val conf = Path("test/$platform/ebics.conf") 122 conf.writeText( 123 """$simpleCfg 124 ${simpleCfg.replace("[nexus-ebics]", "[ebisync]").replace("[nexus-setup]", "[ebisync-setup]")} 125 [paths] 126 LIBEUFIN_NEXUS_HOME = test/$platform 127 EBISYNC_HOME = test/$platform 128 129 [nexus-fetch] 130 FREQUENCY = 1h 131 CHECKPOINT_TIME_OF_DAY = 16:52 132 133 [ebisync-fetch] 134 FREQUENCY = 1h 135 CHECKPOINT_TIME_OF_DAY = 16:52 136 DESTINATION = azure-blob-storage 137 AZURE_API_URL = http://localhost:10000/devstoreaccount1/ 138 AZURE_ACCOUNT_NAME = devstoreaccount1 139 AZURE_ACCOUNT_KEY = Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== 140 AZURE_CONTAINER = test 141 142 [ebisync-submit] 143 SOURCE = ebisync-api 144 AUTH_METHOD = none 145 146 [libeufin-nexusdb-postgres] 147 CONFIG = postgres:///libeufintestbench 148 149 [ebisyncdb-postgres] 150 CONFIG = postgres:///libeufintestbench 151 """) 152 153 // Prepare shell 154 val terminal = TerminalBuilder.builder().system(true).build()//.signalHandler(Terminal.SignalHandler.SIG_IGN).build() 155 val history = DefaultHistory() 156 val reader = LineReaderBuilder.builder().terminal(terminal).history(history).build(); 157 158 terminal.handle(Terminal.Signal.INT) { 159 thread?.let { 160 thread = null 161 it.interrupt() 162 } ?: run { 163 kotlin.system.exitProcess(0) 164 } 165 } 166 167 val cfg = nexusConfig(conf) 168 169 // Check if platform is known 170 val host = cfg.cfg.section("nexus-ebics").string("host_base_url").orNull() 171 val kind = when (host) { 172 "https://isotest.postfinance.ch/ebicsweb/ebicsweb" -> 173 Kind("PostFinance IsoTest", "https://isotest.postfinance.ch/corporates/user/settings/ebics") 174 "https://iso20022test.credit-suisse.com/ebicsweb/ebicsweb" -> 175 Kind("Credit Suisse isoTest", "https://iso20022test.credit-suisse.com/user/settings/ebics") 176 "https://ebics.postfinance.ch/ebics/ebics.aspx" -> 177 Kind("PostFinance", null) 178 else -> Kind("Unknown", null) 179 } 180 181 // Read testbench config 182 val benchCfg: Config = loadJsonFile(Path("test/platform/config.json"), "testbench config") 183 ?: Config(emptyMap()) 184 185 // Prepare cmds 186 val log = "DEBUG" 187 val flags = " -c $conf -L $log" 188 val debugFlags = "$flags --debug-ebics test/$platform" 189 val ebicsFlags = "$debugFlags --transient" 190 val clientKeysPath = cfg.ebics.clientPrivateKeysPath 191 val bankKeysPath = cfg.ebics.bankPublicKeysPath 192 val currency = cfg.currency 193 194 val dummyPaytos = mapOf( 195 "CHF" to "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff", 196 "EUR" to "payto://iban/GENODED1SPW/DE48330605920000686018?receiver-name=Christian%20Grothoff" 197 ) 198 val dummyPayto = requireNotNull(dummyPaytos[currency]) { 199 "Missing dummy payto for $currency" 200 } 201 val payto = benchCfg.payto[currency] ?: dummyPayto 202 val recoverDoc = "report statement notification" 203 204 runBlocking { 205 step("Init ${kind.name}") 206 207 val (setup, cmds) = when (component) { 208 Component.Ebisync -> { 209 assert(ebisyncCmd.run("dbinit $flags")) 210 val cmds = buildCmds(ebisyncCmd) { 211 put("reset-db", "Reset DB", "dbinit -r $flags") 212 put("recover", "Recover old transactions", "fetch $ebicsFlags --pinned-start 2024-01-01") 213 put("fetch", "Fetch all documents", "fetch $ebicsFlags") 214 put("fetch-wait", "Fetch all documents", "fetch $debugFlags") 215 put("checkpoint", "Run a transient checkpoint", "fetch $ebicsFlags --checkpoint") 216 put("peek", "Run a transient peek", "fetch $ebicsFlags --peek") 217 put("reset-keys", "Reset EBICS keys") { 218 if (kind.test) { 219 clientKeysPath.deleteIfExists() 220 } 221 bankKeysPath.deleteIfExists() 222 Unit 223 } 224 } 225 Pair(suspend { ebisyncCmd.run("setup $debugFlags") }, cmds) 226 } 227 Component.Nexus -> { 228 assert(nexusCmd.run("dbinit $flags")) 229 val cmds = buildCmds(nexusCmd) { 230 put("reset-db", "Reset DB", "dbinit -r $flags") 231 put("recover", "Recover old transactions", "ebics-fetch $ebicsFlags --pinned-start 2024-01-01 $recoverDoc") 232 put("fetch", "Fetch all documents", "ebics-fetch $ebicsFlags") 233 put("fetch-wait", "Fetch all documents", "ebics-fetch $debugFlags") 234 put("checkpoint", "Run a transient checkpoint", "ebics-fetch $ebicsFlags --checkpoint") 235 put("peek", "Run a transient peek", "ebics-fetch $ebicsFlags --peek") 236 put("ack", "Fetch CustomerAcknowledgement", "ebics-fetch $ebicsFlags acknowledgement") 237 put("status", "Fetch CustomerPaymentStatusReport", "ebics-fetch $ebicsFlags status") 238 put("report", "Fetch BankToCustomerAccountReport", "ebics-fetch $ebicsFlags report") 239 put("notification", "Fetch BankToCustomerDebitCreditNotification", "ebics-fetch $ebicsFlags notification") 240 put("statement", "Fetch BankToCustomerStatement", "ebics-fetch $ebicsFlags statement") 241 put("list-incoming", "List incoming transaction", "list incoming $flags") 242 put("list-outgoing", "List outgoing transaction", "list outgoing $flags") 243 put("list-initiated", "List initiated payments", "list initiated $flags") 244 put("list-ack", "List initiated payments pending manual submission acknowledgement", "list initiated $flags --awaiting-ack") 245 put("wss", "Listen to notification over websocket", "testing wss $debugFlags") 246 put("submit", "Submit pending transactions", "ebics-submit $ebicsFlags") 247 put("submit-wait", "Submit pending transaction", "ebics-submit $debugFlags") 248 put("export", "Export pending batches as pain001 messages", "manual export $flags payments.zip") 249 putArgs("import", "Import xml files in root directory") { 250 buildString { 251 append("manual import $flags ") 252 for (file in Path("..").listDirectoryEntries()) { 253 if (file.extension == "xml") { 254 append(file) 255 append(" ") 256 } 257 } 258 } 259 } 260 putArgs("status", "Set batch or transaction status") { 261 "manual status $flags " + it.joinToString(" ") 262 } 263 put("reset-keys", "Reset EBICS keys") { 264 if (kind.test) { 265 clientKeysPath.deleteIfExists() 266 } 267 bankKeysPath.deleteIfExists() 268 Unit 269 } 270 put("tx", "Initiate a new transaction") { 271 val now = Instant.now() 272 nexusCmd.run("initiate-payment $flags --amount=$currency:0.1 --subject \"single $now\" \"$payto\"") 273 Unit 274 } 275 put("txs", "Initiate four new transactions") { 276 val now = Instant.now() 277 repeat(4) { 278 nexusCmd.run("initiate-payment $flags --amount=$currency:${(10.0+it)/100} --subject \"multi $it $now\" \"$payto\"") 279 } 280 } 281 put("tx-bad-name", "Initiate a new transaction with a bad name") { 282 val badPayto = URLBuilder().takeFrom(payto) 283 badPayto.parameters["receiver-name"] = "John Smith" 284 val now = Instant.now() 285 nexusCmd.run("initiate-payment $flags --amount=$currency:0.21 --subject \"bad name $now\" \"$badPayto\"") 286 Unit 287 } 288 put("tx-bad-iban", "Initiate a new transaction to a bad IBAN") { 289 val badPayto = URLBuilder().takeFrom("payto://iban/XX18500105173385245165") 290 badPayto.parameters["receiver-name"] = "John Smith" 291 val now = Instant.now() 292 nexusCmd.run("initiate-payment $flags --amount=$currency:0.22 --subject \"bad iban $now\" \"$badPayto\"") 293 Unit 294 } 295 put("tx-dummy-iban", "Initiate a new transaction to a dummy IBAN") { 296 val now = Instant.now() 297 nexusCmd.run("initiate-payment $flags --amount=$currency:0.23 --subject \"dummy iban $now\" \"$dummyPayto\"") 298 Unit 299 } 300 put("tx-check", "Check transaction semantic", "testing tx-check $flags") 301 } 302 Pair(suspend { nexusCmd.run("ebics-setup $debugFlags") }, cmds) 303 } 304 } 305 306 307 while (true) { 308 // Automatic setup 309 if (host != null) { 310 var clientKeys = loadClientKeys(clientKeysPath) 311 val bankKeys = loadBankKeys(bankKeysPath) 312 if (!kind.test && clientKeys == null) { 313 msg("Manual setup is required for non test environment") 314 } else if (clientKeys == null || !clientKeys.submitted_ini || !clientKeys.submitted_hia || bankKeys == null || !bankKeys.accepted) { 315 step("Run EBICS setup") 316 if (!setup()) { 317 clientKeys = loadClientKeys(clientKeysPath) 318 if (kind.test) { 319 if (clientKeys == null || !clientKeys.submitted_ini || !clientKeys.submitted_hia) { 320 msg("Got to ${kind.settings} and click on 'Reset EBICS user'") 321 } else { 322 msg("Got to ${kind.settings} and click on 'Activate EBICS user'") 323 } 324 } else { 325 msg("Activate your keys at your bank") 326 } 327 } 328 } 329 } 330 // REPL 331 val line = try { 332 reader.readLine("testbench> ")!! 333 } catch (e: UserInterruptException) { 334 print(ANSI.red("^C")) 335 System.out.flush() 336 throw ProgramResult(1) 337 } 338 val args = line.split(WORDS_REGEX).toMutableList() 339 val cmdArg = args.removeFirstOrNull() 340 val cmd = cmds[cmdArg] 341 if (cmd != null) { 342 step(cmd.first) 343 cmd.second(args) 344 } else { 345 when (cmdArg) { 346 "" -> continue 347 "exit" -> break 348 "?", "help" -> { 349 println("Commands:") 350 println(" setup - Setup") 351 for ((name, cmd) in cmds) { 352 println(" $name - ${cmd.first}") 353 } 354 } 355 "setup" -> { 356 step("Setup") 357 setup() 358 } 359 else -> err("Unknown command '$cmdArg'") 360 } 361 } 362 } 363 } 364 } 365 } 366 367 fun main(args: Array<String>) { 368 setupSecurityProperties() 369 Cli().main(args) 370 } 371 372 typealias Cmds = Map<String, Pair<String, suspend (List<String>) -> Unit>> 373 374 data class CmdsBuilder( 375 private val cmd: CliktCommand, 376 val map: MutableMap<String, Pair<String, suspend (List<String> 377 ) -> Unit>>) { 378 fun putCmd(name: String, step: String, lambda: suspend (List<String>) -> Unit) { 379 map.put(name, Pair(step, lambda)) 380 } 381 fun put(name: String, step: String, lambda: suspend () -> Unit) { 382 putCmd(name, step, { 383 lambda() 384 Unit 385 }) 386 } 387 fun put(name: String, step: String, args: String) { 388 put(name, step, { 389 cmd.run(args) 390 Unit 391 }) 392 } 393 fun putArgs(name: String, step: String, parser: (List<String>) -> String) { 394 putCmd(name, step, { args: List<String> -> 395 cmd.run(parser(args)) 396 Unit 397 }) 398 } 399 } 400 401 fun buildCmds(cmd: CliktCommand, actions: CmdsBuilder.() -> Unit): Cmds { 402 val builder = CmdsBuilder(cmd, mutableMapOf()) 403 builder.actions() 404 return builder.map 405 }