libeufin

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

commit 5060b676aa729e2e2e57adbdf1a025554728fecf
parent 6bac5cf1c5d642d6e0a0a9afbc6c90cb0291c78b
Author: Antoine A <>
Date:   Tue,  9 Jan 2024 16:30:47 +0000

Semi automated test for netzbon ebics-fetch

Diffstat:
M.gitignore | 1+
MMakefile | 4++++
Mbank/src/main/resources/logback.xml | 3+++
Aintegration/conf/netzbon.conf | 25+++++++++++++++++++++++++
Mintegration/conf/postfinance.conf | 18++++++++++++++----
Mintegration/src/main/kotlin/Main.kt | 146+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Database.kt | 32+++++++++++++++++++++++++++++---
Mnexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt | 74++++++++++++++++++++++++++++----------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt | 29++++++++++++++++++-----------
9 files changed, 206 insertions(+), 126 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -26,6 +26,7 @@ __pycache__ *.log .DS_Store *.mk +*.xsd util/src/main/resources/version.txt debian/usr/share/libeufin/demobank-ui/index.js debian/usr/share/libeufin/demobank-ui/*.html diff --git a/Makefile b/Makefile @@ -102,6 +102,10 @@ check: install-nobuild-bank-files test: install-nobuild-bank-files ./gradlew test --tests $(test) -i +.PHONY: integration +integration: + ./gradlew :integration:run --console=plain --args="$(test)" + .PHONY: doc doc: ./gradlew dokkaHtmlMultiModule diff --git a/bank/src/main/resources/logback.xml b/bank/src/main/resources/logback.xml @@ -12,6 +12,9 @@ <logger name="tech.libeufin.util" level="ALL" additivity="false"> <appender-ref ref="STDERR" /> </logger> + <logger name="tech.libeufin.nexus" level="ALL" additivity="false"> + <appender-ref ref="STDERR" /> + </logger> <logger name="io.netty" level="INFO" /> <logger name="ktor" level="TRACE" /> diff --git a/integration/conf/netzbon.conf b/integration/conf/netzbon.conf @@ -0,0 +1,25 @@ +[nexus-ebics] +CURRENCY = CHF + +# Bank +HOST_BASE_URL = https://ebics.postfinance.ch/ebics/ebics.aspx +BANK_DIALECT = postfinance + +# EBICS IDs +HOST_ID = PFEBICS +USER_ID = 5183101 +PARTNER_ID = 51831 + + +BANK_PUBLIC_KEYS_FILE = test/netzbon/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = test/netzbon/client-keys.json + +IBAN = CH4009000000160948810 +BIC = POFICHBEXXX +NAME = Genossenschaft Netz Soziale Oekonomie + +[nexus-fetch] +STATEMENT_LOG_DIRECTORY = test/netzbon/fetch + +[nexus-postgres] +CONFIG = postgres:///libeufincheck diff --git a/integration/conf/postfinance.conf b/integration/conf/postfinance.conf @@ -1,14 +1,24 @@ [nexus-ebics] currency = CHF -BANK_DIALECT = postfinance + +# Bank HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb +BANK_DIALECT = postfinance + +# EBICS IDs +HOST_ID = PFEBICS +USER_ID = PFC00563 +PARTNER_ID = PFC00563 + +# Key files BANK_PUBLIC_KEYS_FILE = test/postfinance/bank-keys.json CLIENT_PRIVATE_KEYS_FILE = test/postfinance/client-keys.json + #IBAN = CH2989144971918294289 IBAN = CH7789144474425692816 -HOST_ID = PFEBICS -USER_ID = PFC00563 -PARTNER_ID = PFC00563 + +[nexus-fetch] +STATEMENT_LOG_DIRECTORY = test/postfinance/fetch [nexus-postgres] CONFIG = postgres:///libeufincheck diff --git a/integration/src/main/kotlin/Main.kt b/integration/src/main/kotlin/Main.kt @@ -25,6 +25,8 @@ import tech.libeufin.nexus.* import tech.libeufin.bank.* import tech.libeufin.util.* import com.github.ajalt.clikt.core.* +import com.github.ajalt.clikt.parameters.arguments.* +import com.github.ajalt.clikt.parameters.types.* import com.github.ajalt.clikt.testing.* import io.ktor.client.* import io.ktor.client.engine.cio.* @@ -64,84 +66,104 @@ fun CliktCommandTestResult.assertErr(msg: String? = null) { assertEquals(1, statusCode, msg) } -class PostFinanceCli : CliktCommand("Run tests on postfinance", name="postfinance") { +enum class Kind { + postfinance, + netzbon +} + +class Cli : CliktCommand("Run integration tests on banks provider") { + val kind: Kind by argument().enum<Kind>() override fun run() { + val name = kind.name + step("Test init $name") + runBlocking { - Path("test/postfinance").createDirectories() - val conf = "conf/postfinance.conf" + Path("test/$name").createDirectories() + val conf = "conf/$name.conf" val cfg = loadConfig(conf) + val clientKeysPath = Path(cfg.requireString("nexus-ebics", "client_private_keys_file")) val bankKeysPath = Path(cfg.requireString("nexus-ebics", "bank_public_keys_file")) var hasClientKeys = clientKeysPath.exists() var hasBankKeys = bankKeysPath.exists() - if (hasClientKeys || hasBankKeys) { - if (ask("Reset keys ? y/n>") == "y") { - if (hasClientKeys) clientKeysPath.deleteIfExists() - if (hasBankKeys) bankKeysPath.deleteIfExists() - hasClientKeys = false - hasBankKeys = false - } - } - - if (!hasClientKeys) { - step("Test INI order") - ask("Got to https://testplattform.postfinance.ch/corporates/user/settings/ebics and click on 'Reset EBICS user'.\nPress Enter when done>") - nexusCmd.test("ebics-setup -c $conf") - .assertErr("ebics-setup should failed the first time") - } - - if (!hasBankKeys) { - step("Test HIA order") - ask("Got to https://testplattform.postfinance.ch/corporates/user/settings/ebics and click on 'Activate EBICS user'.\nPress Enter when done>") - nexusCmd.test("ebics-setup --auto-accept-keys -c $conf") - .assertOk("ebics-setup should succeed the second time") - } - - if (ask("Submit transactions ? y/n>") == "y") { - val payto = "payto://iban/CH2989144971918294289?receiver-name=Test" - - step("Test submit one transaction") - val nexusDb = NexusDb("postgresql:///libeufincheck") - nexusCmd.test("dbinit -r -c $conf").assertOk() - nexusDb.initiatedPaymentCreate(InitiatedPayment( - amount = NexusAmount(42L, 0, "CFH"), - creditPaytoUri = payto, - wireTransferSubject = "single transaction test", - initiationTime = Instant.now(), - requestUid = Base32Crockford.encode(randBytes(16)) - )) - nexusCmd.test("ebics-submit --transient -c $conf").assertOk() - - step("Test submit many transaction") - repeat(4) { + nexusCmd.test("dbinit -r -c $conf").assertOk() + val nexusDb = NexusDb("postgresql:///libeufincheck") + + when (kind) { + Kind.postfinance -> { + if (hasClientKeys || hasBankKeys) { + if (ask("Reset keys ? y/n>") == "y") { + if (hasClientKeys) clientKeysPath.deleteIfExists() + if (hasBankKeys) bankKeysPath.deleteIfExists() + hasClientKeys = false + hasBankKeys = false + } + } + + if (!hasClientKeys) { + step("Test INI order") + ask("Got to https://testplattform.postfinance.ch/corporates/user/settings/ebics and click on 'Reset EBICS user'.\nPress Enter when done>") + nexusCmd.test("ebics-setup -c $conf") + .assertErr("ebics-setup should failed the first time") + } + + if (!hasBankKeys) { + step("Test HIA order") + ask("Got to https://testplattform.postfinance.ch/corporates/user/settings/ebics and click on 'Activate EBICS user'.\nPress Enter when done>") + nexusCmd.test("ebics-setup --auto-accept-keys -c $conf") + .assertOk("ebics-setup should succeed the second time") + } + + if (ask("Submit transactions ? y/n>") == "y") { + val payto = "payto://iban/CH2989144971918294289?receiver-name=Test" + + step("Test submit one transaction") nexusDb.initiatedPaymentCreate(InitiatedPayment( - amount = NexusAmount(100L + it, 0, "CFH"), - creditPaytoUri = payto, - wireTransferSubject = "multi transaction test $it", - initiationTime = Instant.now(), - requestUid = Base32Crockford.encode(randBytes(16)) - )) + amount = NexusAmount(42L, 0, "CFH"), + creditPaytoUri = payto, + wireTransferSubject = "single transaction test", + initiationTime = Instant.now(), + requestUid = Base32Crockford.encode(randBytes(16)) + )) + nexusCmd.test("ebics-submit --transient -c $conf").assertOk() + + step("Test submit many transaction") + repeat(4) { + nexusDb.initiatedPaymentCreate(InitiatedPayment( + amount = NexusAmount(100L + it, 0, "CFH"), + creditPaytoUri = payto, + wireTransferSubject = "multi transaction test $it", + initiationTime = Instant.now(), + requestUid = Base32Crockford.encode(randBytes(16)) + )) + } + nexusCmd.test("ebics-submit --transient -c $conf").assertOk() + } + + step("Test fetch transactions") + nexusCmd.test("ebics-fetch --transient -c $conf --pinned-start 2022-01-01").assertOk() + } + Kind.netzbon -> { + if (!hasClientKeys) + throw Exception("Clients keys are required to run netzbon tests") + + if (!hasBankKeys) { + step("Test HIA order") + nexusCmd.test("ebics-setup --auto-accept-keys -c $conf").assertOk("ebics-setup should succeed the second time") + } + + step("Test fetch transactions") + nexusCmd.test("ebics-fetch --transient -c $conf --pinned-start 2022-01-01").assertOk() } - nexusCmd.test("ebics-submit --transient -c $conf").assertOk() } - - step("Test fetch transactions") - nexusCmd.test("ebics-fetch --transient -c $conf --pinned-start 2022-01-01").assertOk() - - step("Test succeed") } + + step("Test succeed") } } -class Cli : CliktCommand() { - init { - subcommands(PostFinanceCli()) - } - override fun run() = Unit -} - fun main(args: Array<String>) { Cli().main(args) } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Database.kt @@ -9,6 +9,14 @@ import tech.libeufin.util.* import java.sql.PreparedStatement import java.sql.SQLException import java.time.Instant +import java.util.Date +import java.text.SimpleDateFormat + +fun Instant.fmtDate(): String { + val formatter = SimpleDateFormat("yyyy-MM-dd") + return formatter.format(Date.from(this)) +} + // Remove this once TalerAmount from the bank // module gets moved to the 'util' module (#7987). @@ -16,7 +24,16 @@ data class TalerAmount( val value: Long, val fraction: Int, // has at most 8 digits. val currency: String -) +) { + override fun toString(): String { + if (fraction == 0) { + return "$currency:$value" + } else { + return "$currency:$value.${fraction.toString().padStart(8, '0')}" + .dropLastWhile { it == '0' } // Trim useless fractional trailing 0 + } + } +} // INCOMING PAYMENTS STRUCTS @@ -29,7 +46,12 @@ data class IncomingPayment( val debitPaytoUri: String, val executionTime: Instant, val bankTransferId: String -) +) { + override fun toString(): String { + return ">> ${executionTime.fmtDate()} $amount $bankTransferId debitor=$debitPaytoUri subject=$wireTransferSubject" + } +} + // INITIATED PAYMENTS STRUCTS @@ -104,7 +126,11 @@ data class OutgoingPayment( val bankTransferId: String, val creditPaytoUri: String? = null, // not showing in camt.054 val wireTransferSubject: String? = null // not showing in camt.054 -) +) { + override fun toString(): String { + return "<< ${executionTime.fmtDate()} $amount $bankTransferId creditor=$creditPaytoUri subject=$wireTransferSubject" + } +} /** * Witnesses the outcome of inserting an outgoing diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -18,7 +18,7 @@ import java.time.LocalDate import java.time.ZoneId import java.util.UUID import kotlin.concurrent.fixedRateTimer -import kotlin.io.path.createDirectories +import kotlin.io.path.* /** * Necessary data to perform a download. @@ -140,25 +140,20 @@ fun maybeLogFile( val asUtcDate = LocalDate.ofInstant(now, ZoneId.of("UTC")) val subDir = "${asUtcDate.year}-${asUtcDate.monthValue}-${asUtcDate.dayOfMonth}" // Creating the combined dir. - val dirs = Path.of(maybeLogDir, subDir) + val dirs = Path(maybeLogDir, subDir) dirs.createDirectories() - fun maybeWrite(f: File, xml: String) { - if (f.exists()) { - throw Exception("Log file exists already at: ${f.path}") - } - f.writeText(xml) - } if (nonZip) { - val f = File(dirs.toString(), "${now.toDbMicros()}_HAC_response.pain.002.xml") - maybeWrite(f, content.toString(Charsets.UTF_8)) - return - } - // Write each ZIP entry in the combined dir. - content.unzipForEach { fileName, xmlContent -> - val f = File(dirs.toString(), "${now.toDbMicros()}_$fileName") - // Rare: cannot download the same file twice in the same microsecond. - maybeWrite(f, xmlContent) + val f = Path(dirs.toString(), "${now.toDbMicros()}_HAC_response.pain.002.xml") + f.writeBytes(content) + } else { + // Write each ZIP entry in the combined dir. + content.unzipForEach { fileName, xmlContent -> + val f = Path(dirs.toString(), "${now.toDbMicros()}_$fileName") + // Rare: cannot download the same file twice in the same microsecond. + f.writeText(xmlContent) + } } + } /** @@ -371,50 +366,36 @@ fun firstLessThanSecond( * @param db database connection. * @param content the ZIP file that contains the EBICS * notification as camt.054 records. - * @return true if the ingestion succeeded, false otherwise. - * False should fail the process, since it means that - * the notification could not be parsed. */ private fun ingestNotification( db: Database, ctx: FetchContext, content: ByteArray -): Boolean { +) { val incomingPayments = mutableListOf<IncomingPayment>() val outgoingPayments = mutableListOf<OutgoingPayment>() - val filenamePrefixForIncoming = "camt.054_P_${ctx.cfg.myIbanAccount.iban}" - val filenamePrefixForOutgoing = "camt.054-Debit_P_${ctx.cfg.myIbanAccount.iban}" + try { content.unzipForEach { fileName, xmlContent -> if (!fileName.contains("camt.054", ignoreCase = true)) throw Exception("Asked for notification but did NOT get a camt.054") - + logger.debug("parse $fileName") parseTxNotif(xmlContent, ctx.cfg.currency, incomingPayments, outgoingPayments) } } catch (e: IOException) { - logger.error("Could not open any ZIP archive") - return false - } catch (e: Exception) { - logger.error(e.message) - return false + throw Exception("Could not open any ZIP archive", e) } - try { - runBlocking { - incomingPayments.forEach { - logger.debug("incoming tx: $it") - ingestIncomingPayment(db, it) - } - outgoingPayments.forEach { - logger.debug("outgoing tx: $it") - ingestOutgoingPayment(db, it) - } + runBlocking { + incomingPayments.forEach { + logger.debug("$it") + ingestIncomingPayment(db, it) + } + outgoingPayments.forEach { + logger.debug("$it") + ingestOutgoingPayment(db, it) } - } catch (e: Exception) { - logger.error(e.message) - return false } - return true } /** @@ -469,8 +450,10 @@ private suspend fun fetchDocuments( logger.warn("Not ingesting ${ctx.whichDocument}. Only camt.054 notifications supported.") return } - if (!ingestNotification(db, ctx, maybeContent)) { - throw Exception("Ingesting notifications failed") + try { + ingestNotification(db, ctx, maybeContent) + } catch (e: Exception) { + throw Exception("Ingesting notifications failed", e) } } @@ -545,7 +528,6 @@ class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 noti SupportedDocument.CAMT_054 -> { val incomingTxs = mutableListOf<IncomingPayment>() val outgoingTxs = mutableListOf<OutgoingPayment>() - parseTxNotif(maybeStdin, cfg.currency, incomingTxs, outgoingTxs) println(incomingTxs) println(outgoingTxs) diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Iso20022.kt @@ -189,13 +189,18 @@ fun parseTxNotif( focusElement.textContent } } - // Obtaining payment subject. - val subject = StringBuilder() - requireUniqueChildNamed("RmtInf") { - this.mapEachChildNamed("Ustrd") { - val piece = this.focusElement.textContent + // Obtaining payment subject. + val subject = maybeUniqueChildNamed("RmtInf") { + val subject = StringBuilder() + mapEachChildNamed("Ustrd") { + val piece = focusElement.textContent subject.append(piece) } + subject + } + if (subject == null) { + logger.debug("Skip notification $uidFromBank, missing subject") + return@notificationForEachTx } // Obtaining the payer's details @@ -210,7 +215,7 @@ fun parseTxNotif( } // warn: it might need the postal address too.. requireUniqueChildNamed("Dbtr") { - requireUniqueChildNamed("Pty") { + maybeUniqueChildNamed("Pty") { requireUniqueChildNamed("Nm") { val urlEncName = URLEncoder.encode(focusElement.textContent, "utf-8") debtorPayto.append("?receiver-name=$urlEncName") @@ -275,11 +280,13 @@ private fun notificationForEachTx( mapEachChildNamed("Ntfctn") { mapEachChildNamed("Ntry") { requireUniqueChildNamed("Sts") { - requireUniqueChildNamed("Cd") { - if (focusElement.textContent != "BOOK") - throw Exception("Found non booked transaction, " + - "stop parsing. Status was: ${focusElement.textContent}" - ) + if (focusElement.textContent != "BOOK") { + requireUniqueChildNamed("Cd") { + if (focusElement.textContent != "BOOK") + throw Exception("Found non booked transaction, " + + "stop parsing. Status was: ${focusElement.textContent}" + ) + } } } val bookDate: Instant = requireUniqueChildNamed("BookgDt") {