libeufin

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

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 }