libeufin

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

Main.kt (17118B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2023, 2024, 2025, 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 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                         }
    223                     }
    224                     Pair(suspend { ebisyncCmd.run("setup $debugFlags") }, cmds)
    225                 }
    226                 Component.Nexus -> {
    227                     assert(nexusCmd.run("dbinit $flags"))
    228                     val cmds = buildCmds(nexusCmd) {
    229                         put("reset-db", "Reset DB", "dbinit -r $flags")
    230                         put("recover", "Recover old transactions", "ebics-fetch $ebicsFlags --pinned-start 2024-01-01 $recoverDoc")
    231                         put("fetch", "Fetch all documents", "ebics-fetch $ebicsFlags")
    232                         put("fetch-wait", "Fetch all documents", "ebics-fetch $debugFlags")
    233                         put("checkpoint", "Run a transient checkpoint", "ebics-fetch $ebicsFlags --checkpoint")
    234                         put("peek", "Run a transient peek", "ebics-fetch $ebicsFlags --peek")
    235                         put("ack", "Fetch CustomerAcknowledgement", "ebics-fetch $ebicsFlags acknowledgement")
    236                         put("status", "Fetch CustomerPaymentStatusReport", "ebics-fetch $ebicsFlags status")
    237                         put("report", "Fetch BankToCustomerAccountReport", "ebics-fetch $ebicsFlags report")
    238                         put("notification", "Fetch BankToCustomerDebitCreditNotification", "ebics-fetch $ebicsFlags notification")
    239                         put("statement", "Fetch BankToCustomerStatement", "ebics-fetch $ebicsFlags statement")
    240                         put("list-incoming", "List incoming transaction", "list incoming $flags")
    241                         put("list-outgoing", "List outgoing transaction", "list outgoing $flags")
    242                         put("list-initiated", "List initiated payments", "list initiated $flags")
    243                         put("list-ack", "List initiated payments pending manual submission acknowledgement", "list initiated $flags --awaiting-ack")
    244                         put("wss", "Listen to notification over websocket", "testing wss $debugFlags")
    245                         put("submit", "Submit pending transactions", "ebics-submit $ebicsFlags")
    246                         put("submit-wait", "Submit pending transaction", "ebics-submit $debugFlags")
    247                         put("export", "Export pending batches as pain001 messages", "manual export $flags payments.zip")
    248                         putArgs("import", "Import xml files in root directory") {
    249                             buildString {
    250                                 append("manual import $flags ")
    251                                 for (file in Path("..").listDirectoryEntries()) {
    252                                     if (file.extension == "xml") {
    253                                         append(file)
    254                                         append(" ")
    255                                     }
    256                                 }
    257                             }
    258                         }
    259                         putArgs("status", "Set batch or transaction status") {
    260                             "manual status $flags " + it.joinToString(" ")
    261                         }
    262                         put("reset-keys", "Reset EBICS keys") {
    263                             if (kind.test) {
    264                                 clientKeysPath.deleteIfExists()
    265                             }
    266                             bankKeysPath.deleteIfExists()
    267                         }
    268                         put("tx", "Initiate a new transaction") {
    269                             val now = Instant.now()
    270                             nexusCmd.run("initiate-payment $flags --amount=$currency:0.1 --subject \"single $now\" \"$payto\"")
    271                         }
    272                         put("txs", "Initiate four new transactions") {
    273                             val now = Instant.now()
    274                             repeat(4) {
    275                                 nexusCmd.run("initiate-payment $flags --amount=$currency:${(10.0+it)/100} --subject \"multi $it $now\" \"$payto\"")
    276                             }
    277                         }
    278                         put("tx-bad-name", "Initiate a new transaction with a bad name") {
    279                             val badPayto = URLBuilder().takeFrom(payto)
    280                             badPayto.parameters["receiver-name"] = "John Smith"
    281                             val now = Instant.now()
    282                             nexusCmd.run("initiate-payment $flags --amount=$currency:0.21 --subject \"bad name $now\" \"$badPayto\"")
    283                         }
    284                         put("tx-bad-iban", "Initiate a new transaction to a bad IBAN") {
    285                             val badPayto = URLBuilder().takeFrom("payto://iban/XX18500105173385245165")
    286                             badPayto.parameters["receiver-name"] = "John Smith"
    287                             val now = Instant.now()
    288                             nexusCmd.run("initiate-payment $flags --amount=$currency:0.22 --subject \"bad iban $now\" \"$badPayto\"")
    289                         }
    290                         put("tx-dummy-iban", "Initiate a new transaction to a dummy IBAN") {
    291                             val now = Instant.now()
    292                             nexusCmd.run("initiate-payment $flags --amount=$currency:0.23 --subject \"dummy iban $now\" \"$dummyPayto\"")
    293                         }
    294                         put("tx-check", "Check transaction semantic", "testing tx-check $flags")
    295                     }
    296                     Pair(suspend { nexusCmd.run("ebics-setup $debugFlags") }, cmds)
    297                 }
    298             }
    299             
    300             
    301             while (true) {
    302                 // Automatic setup
    303                 if (host != null) {
    304                     var clientKeys = loadClientKeys(clientKeysPath)
    305                     val bankKeys = loadBankKeys(bankKeysPath)
    306                     if (!kind.test && clientKeys == null) {
    307                         msg("Manual setup is required for non test environment")
    308                     } else if (clientKeys == null || !clientKeys.submitted_ini || !clientKeys.submitted_hia || bankKeys == null || !bankKeys.accepted) {
    309                         step("Run EBICS setup")
    310                         if (!setup()) {
    311                             clientKeys = loadClientKeys(clientKeysPath)
    312                             if (kind.test) {
    313                                 if (clientKeys == null || !clientKeys.submitted_ini || !clientKeys.submitted_hia) {
    314                                     msg("Got to ${kind.settings} and click on 'Reset EBICS user'")
    315                                 } else {
    316                                     msg("Got to ${kind.settings} and click on 'Activate EBICS user'")
    317                                 }
    318                             } else {
    319                                 msg("Activate your keys at your bank")
    320                             }
    321                         }
    322                     }
    323                 }
    324                 // REPL
    325                 val line = try {
    326                     reader.readLine("testbench> ")!!
    327                 } catch (e: UserInterruptException) {
    328                     print(ANSI.red("^C"))
    329                     System.out.flush()
    330                     throw ProgramResult(1)
    331                 }
    332                 val args = line.split(WORDS_REGEX).toMutableList()
    333                 val cmdArg = args.removeFirstOrNull()
    334                 val cmd = cmds[cmdArg]
    335                 if (cmd != null) {
    336                     step(cmd.first)
    337                     cmd.second(args)
    338                 } else {
    339                     when (cmdArg) {
    340                         "" -> continue
    341                         "exit" -> break
    342                         "?", "help" -> {
    343                             println("Commands:")
    344                             println("  setup - Setup")
    345                             for ((name, cmd) in cmds) {
    346                                 println("  $name - ${cmd.first}")
    347                             }
    348                         }
    349                         "setup" -> {
    350                             step("Setup")
    351                             setup()
    352                         }
    353                         else -> err("Unknown command '$cmdArg'")
    354                     }
    355                 }
    356             }
    357         }
    358     }
    359 }
    360 
    361 fun main(args: Array<String>) {
    362     setupSecurityProperties()
    363     Cli().main(args)
    364 }
    365 
    366 typealias Cmds = Map<String, Pair<String, suspend (List<String>) -> Unit>>
    367 
    368 data class CmdsBuilder(
    369     private val cmd: CliktCommand,
    370     val map: MutableMap<String, Pair<String, suspend (List<String>
    371 ) -> Unit>>) {
    372     fun putCmd(name: String, step: String, lambda: suspend (List<String>) -> Unit) {
    373         map[name] = Pair(step, lambda)
    374     }
    375     fun put(name: String, step: String, lambda: suspend () -> Unit) {
    376         putCmd(name = name, step = step, lambda = {
    377             lambda()
    378         })
    379     }
    380     fun put(name: String, step: String, args: String) {
    381         put(name, step) {
    382             cmd.run(args)
    383         }
    384     }
    385     fun putArgs(name: String, step: String, parser: (List<String>) -> String) {
    386         putCmd(name, step) { args: List<String> ->
    387             cmd.run(parser(args))
    388         }
    389     }
    390 }
    391 
    392 fun buildCmds(cmd: CliktCommand, actions: CmdsBuilder.() -> Unit): Cmds {
    393     val builder = CmdsBuilder(cmd, mutableMapOf())
    394     builder.actions()
    395     return builder.map
    396 }