commit 9000c0a5d57dbfb14f4e69900af5c3b4968d8ec5 parent 9293ac16329d27ff89a7c5f840d7b282cfcbda61 Author: Antoine A <> Date: Wed, 17 Dec 2025 19:09:31 +0100 ebisync: unit tests Diffstat:
22 files changed, 919 insertions(+), 481 deletions(-)
diff --git a/libeufin-ebics/build.gradle b/libeufin-ebics/build.gradle @@ -36,8 +36,9 @@ dependencies { // Serialization implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") + // Unit testing - testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") + implementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") testImplementation("io.ktor:ktor-server-test-host:$ktor_version") testImplementation("io.ktor:ktor-server-cio:$ktor_version") } \ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/http.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/http.kt @@ -19,9 +19,12 @@ package tech.libeufin.ebics +import io.ktor.http.* +import io.ktor.http.content.* import io.ktor.client.* +import io.ktor.client.request.* import io.ktor.client.plugins.* -import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.* import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -37,4 +40,38 @@ fun httpClient(): HttpClient = MOCK_ENGINE?.let { // It can take a lot of time for the bank to generate documents socketTimeoutMillis = 5 * 60 * 1000 } +} + +/** Gets an HTTP client whose requests are going to be served by 'handler' */ +fun getMockedClient( + handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData +): HttpClient = HttpClient(MockEngine) { + followRedirects = false + engine { + addHandler { + request -> handler(request) + } + } +} + +private lateinit var steps: Iterator<(String) -> ByteArray> + +object BadRequest: Exception() + +fun setMock(sequences: Sequence<(String) -> ByteArray>) { + steps = sequences.iterator() + if (MOCK_ENGINE == null) { + val cfg: MockEngineConfig = MockEngineConfig() + cfg.addHandler { req -> + val body = String((req.body as OutgoingContent.ByteArrayContent).bytes()) + val handler = steps.next() + try { + val res = handler(body) + respond(res) + } catch (e: BadRequest) { + respondBadRequest() + } + } + MOCK_ENGINE = MockEngine(cfg) + } } \ No newline at end of file diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/test/TxCheck.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/test/TxCheck.kt @@ -25,8 +25,6 @@ import tech.libeufin.ebics.* import org.slf4j.Logger import org.slf4j.LoggerFactory -private val logger: Logger = LoggerFactory.getLogger("libeufin-nexus") - data class TxCheckResult( var concurrentFetchAndFetch: Boolean = false, var concurrentFetchAndSubmit: Boolean = false, diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/test/ebics.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/test/ebics.kt @@ -0,0 +1,485 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.ebics.test + +import org.w3c.dom.Document +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.testing.test +import io.ktor.http.* +import io.ktor.http.content.* +import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.ebics.* +import kotlin.io.path.* +import kotlin.test.* +import java.security.interfaces.RSAPrivateCrtKey +import java.security.interfaces.RSAPublicKey +import java.time.LocalDate + +class EbicsState { + private val bankSignKey: RSAPrivateCrtKey = CryptoUtil.genRSAPrivate(2048) + private val bankEncKey: RSAPrivateCrtKey = CryptoUtil.genRSAPrivate(2048) + private val bankAuthKey: RSAPrivateCrtKey = CryptoUtil.genRSAPrivate(2048) + + private var clientSignPub: RSAPublicKey? = null + private var clientEncrPub: RSAPublicKey? = null + private var clientAuthPub: RSAPublicKey? = null + + private var transactionId: String? = null + private var orderId: String? = null + + companion object { + private val HEV_OK = XmlBuilder.toBytes("ebicsHEVResponse") { + attr("xmlns", "http://www.ebics.org/H000") + el("SystemReturnCode") { + el("ReturnCode", "000000") + el("ReportText", "[EBICS_OK] OK") + } + el("VersionNumber") { + attr("ProtocolVersion", "H005") + text("03.00") + } + } + private val KEY_OK = XmlBuilder.toBytes("ebicsKeyManagementResponse") { + attr("xmlns", "http://www.ebics.org/H005") + el("header") { + attr("authenticate", "true") + el("mutable") { + el("ReturnCode", "000000") + el("ReportText", "[EBICS_OK] OK") + } + } + el("body") { + el("ReturnCode") { + attr("authenticate", "true") + text("000000") + } + } + } + + private fun parseUnsecureRequest(body: String, order: String, root: String, parse: XmlDestructor.() -> Unit) { + XmlDestructor.parse(body, "ebicsUnsecuredRequest") { + val adminOrder = one("header").one("static").one("OrderDetails").one("AdminOrderType").text() + assertEquals(adminOrder, order) + val chunk = one("body").one("DataTransfer").one("OrderData").base64() + val deflated = chunk.inputStream().inflate() + XmlDestructor.parse(deflated, root) { parse() } + } + } + } + + private fun signedResponse(doc: Document): ByteArray { + XMLUtil.signEbicsDocument(doc, bankAuthKey) + return XMLUtil.convertDomToBytes(doc) + } + + private fun ebicsResponsePayload(payload: ByteArray, last: Boolean = true): ByteArray { + transactionId = randEbicsId() + val deflated = payload.inputStream().deflate() + val (transactionKey, encryptedTransactionKey) = CryptoUtil.genEbicsE002Key(clientEncrPub!!) + val encrypted = CryptoUtil.encryptEbicsE002(transactionKey, deflated) + val doc = XmlBuilder.toDom("ebicsResponse", "http://www.ebics.org/H005") { + attr("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.ebics.org/H005") + el("header") { + attr("authenticate", "true") + el("static") { + el("TransactionID", transactionId!!) + el("NumSegments", "1") + } + el("mutable") { + el("TransactionPhase", "Initialisation") + el("SegmentNumber") { + attr("lastSegment", last.toString()) + text("1") + } + el("ReturnCode", "000000") + el("ReportText", "[EBICS_OK] OK") + } + } + el("AuthSignature") + el("body") { + el("DataTransfer") { + el("DataEncryptionInfo") { + attr("authenticate", "true") + el("EncryptionPubKeyDigest") { + attr("Version", "E002") + attr("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256") + text(CryptoUtil.getEbicsPublicKeyHash(clientEncrPub!!).encodeBase64()) + } + el("TransactionKey", encryptedTransactionKey.encodeBase64()) + } + el("OrderData", encrypted.encodeBase64()) + } + el("ReturnCode") { + attr("authenticate", "true") + text("000000") + } + } + } + return signedResponse(doc) + } + + private fun ebicsResponseNoData(): ByteArray { + val doc = XmlBuilder.toDom("ebicsResponse", "http://www.ebics.org/H005") { + attr("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.ebics.org/H005") + el("header") { + attr("authenticate", "true") + el("static") + el("mutable") { + el("TransactionPhase", "Initialisation") + el("ReturnCode", "000000") + el("ReportText", "[EBICS_OK] OK") + } + } + el("AuthSignature") + el("body") { + el("ReturnCode") { + attr("authenticate", "true") + text("090005") + } + } + } + return signedResponse(doc) + } + + fun hev(body: String): ByteArray { + val hostId = XmlDestructor.parse(body, "ebicsHEVRequest") { + one("HostID").text() + } + return HEV_OK + } + + fun ini(body: String): ByteArray { + parseUnsecureRequest(body, "INI", "SignaturePubKeyOrderData") { + clientSignPub = one("SignaturePubKeyInfo") { + val version = one("SignatureVersion").text() + assertEquals(version, "A006") + rsaPubKey() + } + } + return KEY_OK + } + + fun hia(body: String): ByteArray { + parseUnsecureRequest(body, "HIA", "HIARequestOrderData") { + clientAuthPub = one("AuthenticationPubKeyInfo") { + val version = one("AuthenticationVersion").text() + assertEquals(version, "X002") + rsaPubKey() + } + clientEncrPub = one("EncryptionPubKeyInfo") { + val version = one("EncryptionVersion").text() + assertEquals(version, "E002") + rsaPubKey() + } + } + return KEY_OK + } + + fun hpb(body: String): ByteArray { + // Parse HPB request + XmlDestructor.parse(body, "ebicsNoPubKeyDigestsRequest") { + val order = one("header").one("static").one("OrderDetails").one("AdminOrderType").text() + assertEquals(order, "HPB") + } + + val payload = XmlBuilder.toBytes("HPBResponseOrderData") { + el("AuthenticationPubKeyInfo") { + el("PubKeyValue") { + el("RSAKeyValue") { + el("Modulus", bankAuthKey.modulus.encodeBase64()) + el("Exponent", bankAuthKey.publicExponent.encodeBase64()) + } + } + el("AuthenticationVersion", "X002") + } + el("EncryptionPubKeyInfo") { + el("PubKeyValue") { + el("RSAKeyValue") { + el("Modulus", bankEncKey.modulus.encodeBase64()) + el("Exponent", bankEncKey.publicExponent.encodeBase64()) + } + } + el("EncryptionVersion", "E002") + } + }.inputStream().deflate() + + val (transactionKey, encryptedTransactionKey) = CryptoUtil.genEbicsE002Key(clientEncrPub!!) + val encrypted = CryptoUtil.encryptEbicsE002(transactionKey, payload) + + return XmlBuilder.toBytes("ebicsKeyManagementResponse") { + attr("xmlns:ds", "http://www.w3.org/2000/09/xmldsig#") + attr("xmlns", "http://www.ebics.org/H005") + el("header") { + attr("authenticate", "true") + el("mutable") { + el("ReturnCode", "000000") + el("ReportText", "[EBICS_OK] OK") + } + } + el("body") { + el("DataTransfer") { + el("DataEncryptionInfo") { + attr("authenticate", "true") + el("EncryptionPubKeyDigest") { + attr("Version", "E002") + attr("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256") + text(CryptoUtil.getEbicsPublicKeyHash(clientEncrPub!!).encodeBase64()) + } + el("TransactionKey", encryptedTransactionKey.encodeBase64()) + } + el("OrderData", encrypted.encodeBase64()) + } + el("ReturnCode") { + attr("authenticate", "true") + text("000000") + } + } + } + } + + private fun receipt(body: String, ok: Boolean): ByteArray { + XmlDestructor.parse(body, "ebicsRequest") { + one("header") { + val id = one("static").one("TransactionID").text() + assertEquals(id, transactionId) + val phase = one("mutable").one("TransactionPhase").text() + assertEquals(phase, "Receipt") + } + val code = one("body").one("TransferReceipt").one("ReceiptCode").text() + assertEquals(code, if (ok) { "0" } else { "1" }) + } + val response = signedResponse(XmlBuilder.toDom("ebicsResponse", "http://www.ebics.org/H005") { + attr("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.ebics.org/H005") + el("header") { + attr("authenticate", "true") + el("static") { + el("TransactionID", transactionId!!) + } + el("mutable") { + el("TransactionPhase", "Receipt") + el("ReturnCode", "000000") + el("ReportText", "[EBICS_OK] OK") + } + } + el("AuthSignature") + el("body") { + el("ReturnCode") { + attr("authenticate", "true") + text("000000") + } + } + }) + transactionId = null + return response + } + + fun receiptOk(body: String): ByteArray = receipt(body, true) + fun receiptErr(body: String): ByteArray = receipt(body, false) + + fun hkd(body: String): ByteArray { + XmlDestructor.parse(body, "ebicsRequest") { + one("header") { + val adminOrder = one("static").one("OrderDetails").one("AdminOrderType").text() + assertEquals(adminOrder, "HKD") + val phase = one("mutable").one("TransactionPhase").text() + assertEquals(phase, "Initialisation") + } + } + return ebicsResponsePayload( + XmlBuilder.toBytes("HKDResponseOrderData") { + el("PartnerInfo") { + el("AddressInfo") + el("OrderInfo") { + el("AdminOrderType", "BTD") + el("Service") { + el("ServiceName", "STM") + el("Scope", "CH") + el("Container") { + attr("containerType", "ZIP") + } + el("MsgName") { + attr("version", "08") + text("camt.052") + } + } + el("Description") + } + el("OrderInfo") { + el("AdminOrderType", "BTU") + el("Service") { + el("ServiceName", "SCT") + el("MsgName") { + text("pain.001") + } + } + el("Description", "Direct Debit") + } + el("OrderInfo") { + el("AdminOrderType", "BTU") + el("Service") { + el("ServiceName", "SCI") + el("Scope", "DE") + el("MsgName") { + text("pain.001") + } + } + el("Description", "Instant Direct Debit") + } + } + } + ) + } + + fun haa(body: String): ByteArray { + XmlDestructor.parse(body, "ebicsRequest") { + one("header") { + val adminOrder = one("static").one("OrderDetails").one("AdminOrderType").text() + assertEquals(adminOrder, "HAA") + val phase = one("mutable").one("TransactionPhase").text() + assertEquals(phase, "Initialisation") + } + } + return ebicsResponsePayload( + XmlBuilder.toBytes("HAAResponseOrderData") { + el("Service") { + el("ServiceName", "STM") + el("Scope", "CH") + el("Container") { + attr("containerType", "ZIP") + } + el("MsgName") { + attr("version", "08") + text("camt.052") + } + } + } + ) + } + + private fun btdDateCheck(body: String, pinned: LocalDate?): ByteArray { + XmlDestructor.parse(body, "ebicsRequest") { + one("header") { + one("static").one("OrderDetails") { + val adminOrder = one("AdminOrderType").text() + assertEquals(adminOrder, "BTD") + val start = one("BTDOrderParams").opt("DateRange")?.opt("Start")?.date() + assertEquals(start, pinned) + } + val phase = one("mutable").one("TransactionPhase").text() + assertEquals(phase, "Initialisation") + } + } + return ebicsResponseNoData() + } + + fun btdNoData(body: String): ByteArray = btdDateCheck(body, null) + fun btdNoDataNow(body: String): ByteArray = btdDateCheck(body, LocalDate.now()) + fun btdNoDataPinned(body: String): ByteArray = btdDateCheck(body, LocalDate.parse("2024-06-05")) + + fun btuInit(body: String): ByteArray { + XmlDestructor.parse(body, "ebicsRequest") { + one("header") { + one("static").one("OrderDetails") { + val adminOrder = one("AdminOrderType").text() + assertEquals(adminOrder, "BTU") + } + val phase = one("mutable").one("TransactionPhase").text() + assertEquals(phase, "Initialisation") + } + } + transactionId = randEbicsId() + orderId = randEbicsId() + val doc = XmlBuilder.toDom("ebicsResponse", "http://www.ebics.org/H005") { + attr("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.ebics.org/H005") + el("header") { + attr("authenticate", "true") + el("static") { + el("TransactionID", transactionId!!) + } + el("mutable") { + el("TransactionPhase", "Initialisation") + el("OrderID", orderId!!) + el("ReturnCode", "000000") + el("ReportText", "[EBICS_OK] OK") + } + } + el("AuthSignature") + el("body") { + el("ReturnCode") { + attr("authenticate", "true") + text("000000") + } + } + } + return signedResponse(doc) + } + + fun btuPayload(body: String): ByteArray { + lateinit var segment: String + XmlDestructor.parse(body, "ebicsRequest") { + one("header") { + one("static") { + val txid = one("TransactionID").text() + assertEquals(txid, transactionId) + } + one("mutable") { + val phase = one("TransactionPhase").text() + assertEquals(phase, "Transfer") + segment = one("SegmentNumber").text() + } + } + } + val doc = XmlBuilder.toDom("ebicsResponse", "http://www.ebics.org/H005") { + attr("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.ebics.org/H005") + el("header") { + attr("authenticate", "true") + el("static") { + el("TransactionID", transactionId!!) + } + el("mutable") { + el("TransactionPhase", "Transfer") + el("SegmentNumber", segment) + el("OrderID", orderId!!) + el("ReturnCode", "000000") + el("ReportText", "[EBICS_OK] OK") + } + } + el("AuthSignature") + el("body") { + el("ReturnCode") { + attr("authenticate", "true") + text("000000") + } + } + } + return signedResponse(doc) + } + + fun badRequest(body: String): ByteArray { + throw BadRequest + } + + fun initializeTx(body: String): ByteArray = ebicsResponsePayload(ByteArray(0), false) + + fun failure(body: String): ByteArray { + throw Exception("Not reachable") + } +} +\ No newline at end of file diff --git a/libeufin-ebics/src/test/kotlin/EbicsTest.kt b/libeufin-ebics/src/test/kotlin/EbicsTest.kt @@ -0,0 +1,86 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import org.w3c.dom.Document +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.testing.test +import kotlinx.coroutines.runBlocking +import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.client.engine.mock.* +import org.junit.Test +import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.ebics.* +import tech.libeufin.ebics.test.* +import kotlin.io.path.* +import kotlin.test.* +import java.time.LocalDate + +@OptIn(kotlin.io.path.ExperimentalPathApi::class) +class EbicsTest { + private val ebicsLogger = EbicsLogger(null).tx("test").step("step") + + // POSTs an EBICS message to the mock bank. Tests + // the main branches: unreachable bank, non-200 status + // code, and 200. + @Test + fun postMessage() {runBlocking { + assertFailsWith<EbicsError.HTTP> { + getMockedClient { + respondError(HttpStatusCode.NotFound) + }.postToBank("http://ignored.example.com/", ByteArray(0), "Test", ebicsLogger) + }.run { + assertEquals(HttpStatusCode.NotFound, status) + assertEquals("Test: bank HTTP error: 404 Not Found", message) + } + assertFailsWith<EbicsError.Network> { + getMockedClient { + throw Exception("Simulate failure") + }.postToBank("http://ignored.example.com/", ByteArray(0), "Test", ebicsLogger) + }.run { + assertEquals("Test: failed to contact bank", message) + assertEquals("Simulate failure", cause!!.message) + } + assertFailsWith<EbicsError.Protocol> { + getMockedClient { + respondOk("<ebics broken></ebics>") + }.postToBank("http://ignored.example.com/", ByteArray(0), "Test", ebicsLogger) + }.run { + assertEquals("Test: invalid XML bank response", message) + assertEquals("Attribute name \"broken\" associated with an element type \"ebics\" must be followed by the ' = ' character.", cause!!.message) + } + getMockedClient { + respondOk("<ebics></ebics>") + }.postToBank("http://ignored.example.com/", ByteArray(0), "Test", ebicsLogger) + }} + + // Tests that internal repr. of keys lead to valid PDF. + // Mainly tests that the function does not throw any error. + @Test + fun keysPdf() { + val pdf = generateKeysPdf(generateNewKeys(), object: EbicsHostConfig { + override val baseUrl = "https://isotest.postfinance.ch/ebicsweb/ebicsweb" + override val hostId = "PFEBICS" + override val userId = "PFC00563" + override val partnerId = "PFC00563" + }) + Path("/tmp/libeufin-nexus-test-keys.pdf").writeBytes(pdf) + } +} +\ No newline at end of file diff --git a/libeufin-ebisync/build.gradle b/libeufin-ebisync/build.gradle @@ -35,7 +35,6 @@ dependencies { // Ktor client library implementation("io.ktor:ktor-server-core:$ktor_version") implementation("io.ktor:ktor-client-cio:$ktor_version") - implementation("io.ktor:ktor-client-mock:$ktor_version") implementation("io.ktor:ktor-client-websockets:$ktor_version") // PDF generation diff --git a/libeufin-ebisync/conf/test.conf b/libeufin-ebisync/conf/test.conf @@ -1,7 +1,7 @@ [ebisync] -HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb -BANK_PUBLIC_KEYS_FILE = test/tmp/bank-keys.json -CLIENT_PRIVATE_KEYS_FILE = test/tmp/client-keys.json +HOST_BASE_URL = http://localhost:8080/ebicsweb +BANK_PUBLIC_KEYS_FILE = /tmp/ebics-test/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = /tmp/ebics-test/client-keys.json HOST_ID = PFEBICS USER_ID = PFC00563 PARTNER_ID = PFC00563 @@ -9,9 +9,6 @@ PARTNER_ID = PFC00563 [ebisyncdb-postgres] CONFIG = postgresql:///libeufincheck -[ebisync-fetch] -DESTINATION = azure-blob-storage -AZURE_API_URL = http://localhost:10000/devstoreaccount1/ -AZURE_ACCOUNT_NAME = devstoreaccount1 -AZURE_ACCOUNT_KEY = Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw== -AZURE_CONTAINER = test -\ No newline at end of file +[ebisync-submit] +SOURCE = sync-api +AUTH_METHOD = none +\ No newline at end of file diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/Main.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/Main.kt @@ -25,11 +25,16 @@ import io.ktor.client.request.* import io.ktor.client.plugins.* import io.ktor.client.plugins.api.* import io.ktor.client.statement.* +import io.ktor.server.application.* import io.ktor.http.* import io.ktor.util.* -import tech.libeufin.common.setupSecurityProperties -import tech.libeufin.ebics.httpClient -import tech.libeufin.ebisync.cli.LibeufinEbisync +import tech.libeufin.ebics.* +import tech.libeufin.ebisync.* +import tech.libeufin.ebisync.db.Database +import tech.libeufin.ebisync.api.* +import tech.libeufin.ebisync.cli.* +import tech.libeufin.common.* +import tech.libeufin.common.api.* import java.security.Key import java.time.* import java.time.format.DateTimeFormatter @@ -40,6 +45,9 @@ import java.util.Base64 import com.github.ajalt.clikt.core.main import kotlinx.serialization.Serializable import kotlinx.serialization.Contextual +import java.nio.file.Path +import org.slf4j.Logger +import org.slf4j.LoggerFactory fun main(args: Array<String>) { @@ -48,6 +56,10 @@ fun main(args: Array<String>) { LibeufinEbisync().main(args) } +fun Application.ebisyncApi(auth: AuthMethod, client: EbicsClient, spa: Path) = talerApi(LoggerFactory.getLogger("libeufin-ebisync-api")) { + syncApi(auth, client, spa) +} + @Serializable data class TaskStatus( @Contextual diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/api/SyncApi.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/api/SyncApi.kt @@ -32,6 +32,7 @@ import io.ktor.server.routing.* import io.ktor.server.http.content.* import io.ktor.http.content.* import io.ktor.http.* +import io.ktor.utils.io.* import java.nio.file.Path import tech.libeufin.common.VERSION @@ -85,7 +86,7 @@ fun Routing.syncApi(auth: AuthMethod, client: EbicsClient, spa: Path) { } } is PartData.FileItem -> { - xml = part.streamProvider().readBytes() + xml = part.provider().toByteArray() } else -> {} } diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Serve.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Serve.kt @@ -37,11 +37,6 @@ import com.github.ajalt.clikt.parameters.options.option import java.nio.file.Path -fun Application.ebisyncApi(auth: AuthMethod, client: EbicsClient, spa: Path) = talerApi(LoggerFactory.getLogger("libeufin-ebisync-api")) { - syncApi(auth, client, spa) -} - - class Serve : EbicsCmd() { override fun help(context: Context) = "Run libeufin-ebisync HTTP server" diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Setup.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Setup.kt @@ -72,7 +72,7 @@ class Setup: TalerCmd() { forceKeysResubmission, generateRegistrationPdf, autoAcceptKeys, - false + true ) // Check account information @@ -117,18 +117,6 @@ class Setup: TalerCmd() { } } } - - // Check partner info match config - /*val account = partner.accounts.find { it.iban == cfg.ebics.account.iban } - if (account != null) { - if (account.currency != null && account.currency != cfg.currency) - logger.error("Expected CURRENCY '${cfg.currency}' from config got '${account.currency}' from bank") - if (account.bic != cfg.ebics.account.bic) - logger.error("Expected BIC '${cfg.ebics.account.bic}' from config got '${account.bic}' from bank") - } else if (partner.accounts.isNotEmpty()) { - val ibans = partner.accounts.map { it.iban }.joinToString(" ") - logger.error("Expected IBAN ${cfg.ebics.account.iban} from config got $ibans from bank") - }*/ } } logger.info("EBICS ready") diff --git a/libeufin-ebisync/src/test/kotlin/EbicsTest.kt b/libeufin-ebisync/src/test/kotlin/EbicsTest.kt @@ -0,0 +1,77 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import org.w3c.dom.Document +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.testing.test +import kotlinx.coroutines.runBlocking +import io.ktor.http.* +import io.ktor.http.content.* +import org.junit.Test +import tech.libeufin.ebisync.cli.LibeufinEbisync +import tech.libeufin.ebisync.CHECKPOINT_KEY +import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.ebics.test.* +import tech.libeufin.ebics.* +import kotlin.io.path.* +import kotlin.test.* +import java.time.LocalDate + +private fun CliktCommand.fail(cmd: String) { + val result = test(cmd) + require(result.statusCode != 0) { result } +} + +private fun CliktCommand.succeed(cmd: String) { + val result = test(cmd) + require(result.statusCode == 0) { result } +} + +@OptIn(kotlin.io.path.ExperimentalPathApi::class) +class EbicsTest { + private val cmd = LibeufinEbisync() + private val bank = EbicsState() + private val args = "-L TRACE -c conf/test.conf" + + private fun ebicsSetup() { + // Reset current keys + val dir = Path("/tmp/ebics-test") + dir.deleteRecursively() + dir.createDirectories() + + // Set setup mock + setMock(sequence { + yield(bank::hev) + yield(bank::ini) + yield(bank::hia) + yield(bank::hpb) + yield(bank::hkd) + yield(bank::receiptOk) + }) + + // Run setup + cmd.succeed("setup $args --auto-accept-keys") + } + + @Test + fun setup() { + ebicsSetup() + } +} +\ No newline at end of file diff --git a/libeufin-ebisync/src/test/kotlin/SyncApiTest.kt b/libeufin-ebisync/src/test/kotlin/SyncApiTest.kt @@ -0,0 +1,100 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2025 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import io.ktor.http.* +import io.ktor.server.testing.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import org.junit.Test +import tech.libeufin.common.* +import tech.libeufin.common.test.* +import tech.libeufin.ebics.test.* +import tech.libeufin.ebics.* +import tech.libeufin.ebisync.api.* +import kotlin.test.* + +class SynApiTest { + // GET /config + @Test + fun config() = serverSetup { db, bank -> + client.get("/config").assertOkJson<TalerEbiSyncConfig>() + } + + // GET /submit + @Test + fun orders() = serverSetup { db, bank -> + setMock(sequence { + yield(bank::hkd) + yield(bank::receiptOk) + }) + val orders = client.get("/submit").assertOkJson<List<SyncOrder>>() + assertContentEquals(orders, listOf( + SyncOrder("BTU-SCT-pain.001", "Direct Debit"), + SyncOrder("BTU-SCI-DE-pain.001", "Instant Direct Debit"), + )) + } + + // POST /submit + @Test + fun submit() = serverSetup { db, bank -> + client.submitFormWithBinaryData( + url = "/submit", + formData = formData { + } + ).assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MISSING) + client.submitFormWithBinaryData( + url = "/submit", + formData = formData { + append("order", "UNKNOWN") + } + ).assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MISSING) + + setMock(sequence { + yield(bank::hkd) + yield(bank::receiptOk) + }) + client.submitFormWithBinaryData( + url = "/submit", + formData = formData { + append("order", "UNKNOWN") + append("file", "test", Headers.build { + append(HttpHeaders.ContentType, "application/xml") + append(HttpHeaders.ContentDisposition, "filename=\"content.xml\"") + }) + } + ).assertNotFound(TalerErrorCode.END) + + setMock(sequence { + yield(bank::hkd) + yield(bank::receiptOk) + yield(bank::btuInit) + yield(bank::btuPayload) + }) + client.submitFormWithBinaryData( + url = "/submit", + formData = formData { + append("order", "BTU-SCI-DE-pain.001") + append("file", "test", Headers.build { + append(HttpHeaders.ContentType, "application/xml") + append(HttpHeaders.ContentDisposition, "filename=\"content.xml\"") + }) + } + ).assertOkJson<SyncSubmit>() + } +} +\ No newline at end of file diff --git a/libeufin-ebisync/src/test/kotlin/helpers.kt b/libeufin-ebisync/src/test/kotlin/helpers.kt @@ -23,6 +23,8 @@ import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.testing.* import kotlinx.coroutines.runBlocking +import tech.libeufin.ebics.* +import tech.libeufin.ebics.test.* import tech.libeufin.ebisync.* import tech.libeufin.ebisync.db.* import tech.libeufin.common.* @@ -30,9 +32,10 @@ import tech.libeufin.common.test.* import tech.libeufin.common.db.dbInit import tech.libeufin.common.db.pgDataSource import java.nio.file.NoSuchFileException -import kotlin.io.path.Path -import kotlin.io.path.deleteExisting -import kotlin.io.path.readText +import kotlin.io.path.* +import java.nio.file.FileAlreadyExistsException +import java.nio.file.Path +import java.nio.file.StandardOpenOption import kotlin.random.Random import kotlin.test.assertEquals import kotlin.test.assertIs @@ -50,3 +53,54 @@ fun setup( } cfg.withDb(lambda) } + +@OptIn(kotlin.io.path.ExperimentalPathApi::class) +fun serverSetup( + conf: String = "test.conf", + lambda: suspend ApplicationTestBuilder.(Database, EbicsState) -> Unit +) = setup(conf) { db, cfg -> + val bank = EbicsState() + + // Reset current keys + val dir = Path("/tmp/ebics-test") + dir.deleteRecursively() + dir.createDirectories() + + // Set setup mock + setMock(sequence { + yield(bank::hev) + yield(bank::ini) + yield(bank::hia) + yield(bank::hpb) + }) + + val client = httpClient() + val ebicsLogger = EbicsLogger(null) + + val (clientKeys, bankKeys) = ebicsSetup( + client, + ebicsLogger, + cfg, + cfg, + cfg.setup, + false, + false, + true, + true + ) + val ebics = EbicsClient( + cfg, + client, + db.ebics, + ebicsLogger, + clientKeys, + bankKeys + ) + + testApplication { + application { + ebisyncApi((cfg.submit.source as Source.SyncAPI).auth, ebics, cfg.spa) + } + lambda(db, bank) + } +} +\ No newline at end of file diff --git a/libeufin-nexus/build.gradle b/libeufin-nexus/build.gradle @@ -36,7 +36,6 @@ dependencies { // Ktor client library implementation("io.ktor:ktor-server-core:$ktor_version") implementation("io.ktor:ktor-client-cio:$ktor_version") - implementation("io.ktor:ktor-client-mock:$ktor_version") implementation("io.ktor:ktor-client-websockets:$ktor_version") // UNIX domain sockets support (used to connect to PostgreSQL) diff --git a/libeufin-nexus/conf/fetch.conf b/libeufin-nexus/conf/fetch.conf @@ -2,8 +2,8 @@ CURRENCY = CHF BANK_DIALECT = postfinance HOST_BASE_URL = http://localhost:8080/ebicsweb -BANK_PUBLIC_KEYS_FILE = test/fetch/bank-keys.json -CLIENT_PRIVATE_KEYS_FILE = test/fetch/client-keys.json +BANK_PUBLIC_KEYS_FILE = /tmp/ebics-test/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = /tmp/ebics-test/client-keys.json IBAN = CH7789144474425692816 HOST_ID = PFEBICS USER_ID = PFC00563 diff --git a/libeufin-nexus/src/test/kotlin/EbicsTest.kt b/libeufin-nexus/src/test/kotlin/EbicsTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -20,7 +20,7 @@ import org.w3c.dom.Document import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.testing.test -import io.ktor.client.engine.mock.* +import kotlinx.coroutines.runBlocking import io.ktor.http.* import io.ktor.http.content.* import org.junit.Test @@ -28,366 +28,12 @@ import tech.libeufin.nexus.cli.LibeufinNexus import tech.libeufin.nexus.* import tech.libeufin.common.* import tech.libeufin.common.crypto.CryptoUtil +import tech.libeufin.ebics.test.* import tech.libeufin.ebics.* import kotlin.io.path.* import kotlin.test.* -import java.security.interfaces.RSAPrivateCrtKey -import java.security.interfaces.RSAPublicKey import java.time.LocalDate -private object BadRequest: Exception() - -class EbicsState { - private val bankSignKey: RSAPrivateCrtKey = CryptoUtil.genRSAPrivate(2048) - private val bankEncKey: RSAPrivateCrtKey = CryptoUtil.genRSAPrivate(2048) - private val bankAuthKey: RSAPrivateCrtKey = CryptoUtil.genRSAPrivate(2048) - - private var clientSignPub: RSAPublicKey? = null - private var clientEncrPub: RSAPublicKey? = null - private var clientAuthPub: RSAPublicKey? = null - - private var transactionId: String? = null - - companion object { - private val HEV_OK = XmlBuilder.toBytes("ebicsHEVResponse") { - attr("xmlns", "http://www.ebics.org/H000") - el("SystemReturnCode") { - el("ReturnCode", "000000") - el("ReportText", "[EBICS_OK] OK") - } - el("VersionNumber") { - attr("ProtocolVersion", "H005") - text("03.00") - } - } - private val KEY_OK = XmlBuilder.toBytes("ebicsKeyManagementResponse") { - attr("xmlns", "http://www.ebics.org/H005") - el("header") { - attr("authenticate", "true") - el("mutable") { - el("ReturnCode", "000000") - el("ReportText", "[EBICS_OK] OK") - } - } - el("body") { - el("ReturnCode") { - attr("authenticate", "true") - text("000000") - } - } - } - - private fun parseUnsecureRequest(body: String, order: String, root: String, parse: XmlDestructor.() -> Unit) { - XmlDestructor.parse(body, "ebicsUnsecuredRequest") { - val adminOrder = one("header").one("static").one("OrderDetails").one("AdminOrderType").text() - assertEquals(adminOrder, order) - val chunk = one("body").one("DataTransfer").one("OrderData").base64() - val deflated = chunk.inputStream().inflate() - XmlDestructor.parse(deflated, root) { parse() } - } - } - } - - private fun signedResponse(doc: Document): ByteArray { - XMLUtil.signEbicsDocument(doc, bankAuthKey) - return XMLUtil.convertDomToBytes(doc) - } - - private fun ebicsResponsePayload(payload: ByteArray, last: Boolean = true): ByteArray { - transactionId = randEbicsId() - val deflated = payload.inputStream().deflate() - val (transactionKey, encryptedTransactionKey) = CryptoUtil.genEbicsE002Key(clientEncrPub!!) - val encrypted = CryptoUtil.encryptEbicsE002(transactionKey, deflated) - val doc = XmlBuilder.toDom("ebicsResponse", "http://www.ebics.org/H005") { - attr("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.ebics.org/H005") - el("header") { - attr("authenticate", "true") - el("static") { - el("TransactionID", transactionId!!) - el("NumSegments", "1") - } - el("mutable") { - el("TransactionPhase", "Initialisation") - el("SegmentNumber") { - attr("lastSegment", last.toString()) - text("1") - } - el("ReturnCode", "000000") - el("ReportText", "[EBICS_OK] OK") - } - } - el("AuthSignature") - el("body") { - el("DataTransfer") { - el("DataEncryptionInfo") { - attr("authenticate", "true") - el("EncryptionPubKeyDigest") { - attr("Version", "E002") - attr("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256") - text(CryptoUtil.getEbicsPublicKeyHash(clientEncrPub!!).encodeBase64()) - } - el("TransactionKey", encryptedTransactionKey.encodeBase64()) - } - el("OrderData", encrypted.encodeBase64()) - } - el("ReturnCode") { - attr("authenticate", "true") - text("000000") - } - } - } - return signedResponse(doc) - } - - private fun ebicsResponseNoData(): ByteArray { - val doc = XmlBuilder.toDom("ebicsResponse", "http://www.ebics.org/H005") { - attr("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.ebics.org/H005") - el("header") { - attr("authenticate", "true") - el("static") - el("mutable") { - el("TransactionPhase", "Initialisation") - el("ReturnCode", "000000") - el("ReportText", "[EBICS_OK] OK") - } - } - el("AuthSignature") - el("body") { - el("ReturnCode") { - attr("authenticate", "true") - text("090005") - } - } - } - return signedResponse(doc) - } - - fun hev(body: String): ByteArray { - // Parse HEV request - val hostId = XmlDestructor.parse(body, "ebicsHEVRequest") { - one("HostID").text() - } - return HEV_OK - } - - fun ini(body: String): ByteArray { - parseUnsecureRequest(body, "INI", "SignaturePubKeyOrderData") { - clientSignPub = one("SignaturePubKeyInfo") { - val version = one("SignatureVersion").text() - assertEquals(version, "A006") - rsaPubKey() - } - } - return KEY_OK - } - - fun hia(body: String): ByteArray { - parseUnsecureRequest(body, "HIA", "HIARequestOrderData") { - clientAuthPub = one("AuthenticationPubKeyInfo") { - val version = one("AuthenticationVersion").text() - assertEquals(version, "X002") - rsaPubKey() - } - clientEncrPub = one("EncryptionPubKeyInfo") { - val version = one("EncryptionVersion").text() - assertEquals(version, "E002") - rsaPubKey() - } - } - return KEY_OK - } - - fun hpb(body: String): ByteArray { - // Parse HPB request - XmlDestructor.parse(body, "ebicsNoPubKeyDigestsRequest") { - val order = one("header").one("static").one("OrderDetails").one("AdminOrderType").text() - assertEquals(order, "HPB") - } - - val payload = XmlBuilder.toBytes("HPBResponseOrderData") { - el("AuthenticationPubKeyInfo") { - el("PubKeyValue") { - el("RSAKeyValue") { - el("Modulus", bankAuthKey.modulus.encodeBase64()) - el("Exponent", bankAuthKey.publicExponent.encodeBase64()) - } - } - el("AuthenticationVersion", "X002") - } - el("EncryptionPubKeyInfo") { - el("PubKeyValue") { - el("RSAKeyValue") { - el("Modulus", bankEncKey.modulus.encodeBase64()) - el("Exponent", bankEncKey.publicExponent.encodeBase64()) - } - } - el("EncryptionVersion", "E002") - } - }.inputStream().deflate() - - val (transactionKey, encryptedTransactionKey) = CryptoUtil.genEbicsE002Key(clientEncrPub!!) - val encrypted = CryptoUtil.encryptEbicsE002(transactionKey, payload) - - return XmlBuilder.toBytes("ebicsKeyManagementResponse") { - attr("xmlns:ds", "http://www.w3.org/2000/09/xmldsig#") - attr("xmlns", "http://www.ebics.org/H005") - el("header") { - attr("authenticate", "true") - el("mutable") { - el("ReturnCode", "000000") - el("ReportText", "[EBICS_OK] OK") - } - } - el("body") { - el("DataTransfer") { - el("DataEncryptionInfo") { - attr("authenticate", "true") - el("EncryptionPubKeyDigest") { - attr("Version", "E002") - attr("Algorithm", "http://www.w3.org/2001/04/xmlenc#sha256") - text(CryptoUtil.getEbicsPublicKeyHash(clientEncrPub!!).encodeBase64()) - } - el("TransactionKey", encryptedTransactionKey.encodeBase64()) - } - el("OrderData", encrypted.encodeBase64()) - } - el("ReturnCode") { - attr("authenticate", "true") - text("000000") - } - } - } - } - - private fun receipt(body: String, ok: Boolean): ByteArray { - XmlDestructor.parse(body, "ebicsRequest") { - one("header") { - val id = one("static").one("TransactionID").text() - assertEquals(id, transactionId) - val phase = one("mutable").one("TransactionPhase").text() - assertEquals(phase, "Receipt") - } - val code = one("body").one("TransferReceipt").one("ReceiptCode").text() - assertEquals(code, if (ok) { "0" } else { "1" }) - } - val response = signedResponse(XmlBuilder.toDom("ebicsResponse", "http://www.ebics.org/H005") { - attr("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.ebics.org/H005") - el("header") { - attr("authenticate", "true") - el("static") { - el("TransactionID", transactionId!!) - } - el("mutable") { - el("TransactionPhase", "Receipt") - el("ReturnCode", "000000") - el("ReportText", "[EBICS_OK] OK") - } - } - el("AuthSignature") - el("body") { - el("ReturnCode") { - attr("authenticate", "true") - text("000000") - } - } - }) - transactionId = null - return response - } - - fun receiptOk(body: String): ByteArray = receipt(body, true) - fun receiptErr(body: String): ByteArray = receipt(body, false) - - fun hkd(body: String): ByteArray { - XmlDestructor.parse(body, "ebicsRequest") { - one("header") { - val adminOrder = one("static").one("OrderDetails").one("AdminOrderType").text() - assertEquals(adminOrder, "HKD") - val phase = one("mutable").one("TransactionPhase").text() - assertEquals(phase, "Initialisation") - } - } - return ebicsResponsePayload( - XmlBuilder.toBytes("HKDResponseOrderData") { - el("PartnerInfo") { - el("AddressInfo") - el("OrderInfo") { - el("AdminOrderType", "BTD") - el("Service") { - el("ServiceName", "STM") - el("Scope", "CH") - el("Container") { - attr("containerType", "ZIP") - } - el("MsgName") { - attr("version", "08") - text("camt.052") - } - } - el("Description") - } - } - } - ) - } - - fun haa(body: String): ByteArray { - XmlDestructor.parse(body, "ebicsRequest") { - one("header") { - val adminOrder = one("static").one("OrderDetails").one("AdminOrderType").text() - assertEquals(adminOrder, "HAA") - val phase = one("mutable").one("TransactionPhase").text() - assertEquals(phase, "Initialisation") - } - } - return ebicsResponsePayload( - XmlBuilder.toBytes("HAAResponseOrderData") { - el("Service") { - el("ServiceName", "STM") - el("Scope", "CH") - el("Container") { - attr("containerType", "ZIP") - } - el("MsgName") { - attr("version", "08") - text("camt.052") - } - } - } - ) - } - - private fun btdDateCheck(body: String, pinned: LocalDate?): ByteArray { - XmlDestructor.parse(body, "ebicsRequest") { - one("header") { - one("static").one("OrderDetails") { - val adminOrder = one("AdminOrderType").text() - assertEquals(adminOrder, "BTD") - val start = one("BTDOrderParams").opt("DateRange")?.opt("Start")?.date() - assertEquals(start, pinned) - } - val phase = one("mutable").one("TransactionPhase").text() - assertEquals(phase, "Initialisation") - } - } - return ebicsResponseNoData() - } - - fun btdNoData(body: String): ByteArray = btdDateCheck(body, null) - fun btdNoDataNow(body: String): ByteArray = btdDateCheck(body, LocalDate.now()) - fun btdNoDataPinned(body: String): ByteArray = btdDateCheck(body, LocalDate.parse("2024-06-05")) - - fun badRequest(body: String): ByteArray { - throw BadRequest - } - - fun initializeTx(body: String): ByteArray = ebicsResponsePayload(ByteArray(0), false) - - fun failure(body: String): ByteArray { - throw Exception("Not reachable") - } -} - private fun CliktCommand.fail(cmd: String) { val result = test(cmd) require(result.statusCode != 0) { result } @@ -403,27 +49,10 @@ class EbicsTest { private val nexusCmd = LibeufinNexus() private val bank = EbicsState() private val args = "-L TRACE -c conf/fetch.conf" - private val ebicsLogger = EbicsLogger(null).tx("test").step("step") - - private fun setMock(sequences: Sequence<(String) -> ByteArray>) { - val steps = sequences.iterator() - val cfg: MockEngineConfig = MockEngineConfig() - cfg.addHandler { req -> - val body = String((req.body as OutgoingContent.ByteArrayContent).bytes()) - val handler = steps.next() - try { - val res = handler(body) - respond(res) - } catch (e: BadRequest) { - respondBadRequest() - } - } - MOCK_ENGINE = MockEngine(cfg) - } private fun ebicsSetup() { // Reset current keys - val dir = Path("test/fetch") + val dir = Path("/tmp/ebics-test") dir.deleteRecursively() dir.createDirectories() @@ -441,50 +70,8 @@ class EbicsTest { nexusCmd.succeed("ebics-setup $args --auto-accept-keys") } - // POSTs an EBICS message to the mock bank. Tests - // the main branches: unreachable bank, non-200 status - // code, and 200. - @Test - fun postMessage() = conf { config -> - assertFailsWith<EbicsError.HTTP> { - getMockedClient { - respondError(HttpStatusCode.NotFound) - }.postToBank("http://ignored.example.com/", ByteArray(0), "Test", ebicsLogger) - }.run { - assertEquals(HttpStatusCode.NotFound, status) - assertEquals("Test: bank HTTP error: 404 Not Found", message) - } - assertFailsWith<EbicsError.Network> { - getMockedClient { - throw Exception("Simulate failure") - }.postToBank("http://ignored.example.com/", ByteArray(0), "Test", ebicsLogger) - }.run { - assertEquals("Test: failed to contact bank", message) - assertEquals("Simulate failure", cause!!.message) - } - assertFailsWith<EbicsError.Protocol> { - getMockedClient { - respondOk("<ebics broken></ebics>") - }.postToBank("http://ignored.example.com/", ByteArray(0), "Test", ebicsLogger) - }.run { - assertEquals("Test: invalid XML bank response", message) - assertEquals("Attribute name \"broken\" associated with an element type \"ebics\" must be followed by the ' = ' character.", cause!!.message) - } - getMockedClient { - respondOk("<ebics></ebics>") - }.postToBank("http://ignored.example.com/", ByteArray(0), "Test", ebicsLogger) - } - - // Tests that internal repr. of keys lead to valid PDF. - // Mainly tests that the function does not throw any error. - @Test - fun keysPdf() = conf { config -> - val pdf = generateKeysPdf(clientKeys, config.ebics.host) - Path("/tmp/libeufin-nexus-test-keys.pdf").writeBytes(pdf) - } - @Test - fun setup() = setup { _, _ -> + fun setup() { ebicsSetup() } diff --git a/libeufin-nexus/src/test/kotlin/helpers.kt b/libeufin-nexus/src/test/kotlin/helpers.kt @@ -18,7 +18,6 @@ */ import io.ktor.client.* -import io.ktor.client.engine.mock.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* @@ -27,8 +26,7 @@ import kotlinx.coroutines.runBlocking import tech.libeufin.common.* import tech.libeufin.common.db.dbInit import tech.libeufin.common.db.pgDataSource -import tech.libeufin.ebics.generateNewKeys -import tech.libeufin.ebics.randEbicsId +import tech.libeufin.ebics.* import tech.libeufin.nexus.* import tech.libeufin.nexus.cli.registerIncomingPayment import tech.libeufin.nexus.cli.registerOutgoingPayment @@ -70,18 +68,6 @@ const val grothoffPayto = "payto://iban/CH4189144589712575493?receiver-name=Grot val clientKeys = generateNewKeys() -/** Gets an HTTP client whose requests are going to be served by 'handler' */ -fun getMockedClient( - handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData -): HttpClient = HttpClient(MockEngine) { - followRedirects = false - engine { - addHandler { - request -> handler(request) - } - } -} - /** Generates a payment initiation, given its subject */ fun genInitPay( endToEndId: String, diff --git a/testbench/build.gradle b/testbench/build.gradle @@ -27,6 +27,7 @@ dependencies { implementation("org.postgresql:postgresql:$postgres_version") implementation("org.jline:jline:3.30.5") + implementation("io.ktor:ktor-client-mock:$ktor_version") implementation("io.ktor:ktor-server-test-host:$ktor_version") implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") implementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") diff --git a/testbench/conf/test.conf b/testbench/conf/test.conf @@ -0,0 +1,29 @@ +[nexus-ebics] +CURRENCY = CHF +BANK_DIALECT = postfinance +HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb +BANK_PUBLIC_KEYS_FILE = /tmp/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = /tmp/client-keys.json +IBAN = CH7789144474425692816 +HOST_ID = PFEBICS +USER_ID = PFC00563 +PARTNER_ID = PFC00563 +BIC = BIC +NAME = myname + +[libeufin-nexusdb-postgres] +CONFIG = postgres:///libeufincheck + +[nexus-httpd-wire-gateway-api] +ENABLED = YES +AUTH_METHOD = bearer +TOKEN = secret-token + +[nexus-httpd-revenue-api] +ENABLED = YES +AUTH_METHOD = bearer +TOKEN = secret-token + +[nexus-httpd-observability-api] +ENABLED = YES +AUTH_METHOD = none +\ No newline at end of file diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt @@ -103,7 +103,7 @@ class Cli : CliktCommand() { override fun run() { // List available platform - val platforms = Path("test/conf").listDirectoryEntries().mapNotNull { + val platforms = Path("test/platform").listDirectoryEntries().mapNotNull { val fileName = it.fileName.toString() if (fileName == "config.json") { null @@ -117,7 +117,7 @@ class Cli : CliktCommand() { } // Augment config - val simpleCfg = Path("test/conf/$platform.conf").readText() + val simpleCfg = Path("test/platform/$platform.conf").readText() val conf = Path("test/$platform/ebics.conf") conf.writeText( """$simpleCfg @@ -179,7 +179,7 @@ class Cli : CliktCommand() { } // Read testbench config - val benchCfg: Config = loadJsonFile(Path("test/conf/config.json"), "testbench config") + val benchCfg: Config = loadJsonFile(Path("test/platform/config.json"), "testbench config") ?: Config(emptyMap()) // Prepare cmds diff --git a/testbench/src/test/kotlin/Iso20022Test.kt b/testbench/src/test/kotlin/Iso20022Test.kt @@ -66,7 +66,7 @@ class Iso20022Test { val root = Path("test") if (!root.exists()) return for (platform in root.listDirectoryEntries()) { - if (!platform.isDirectory() || platform.fileName.toString() == "conf") continue + if (!platform.isDirectory() || platform.fileName.toString() == "platform") continue // List logs var logs = mutableListOf<Path>() @@ -100,7 +100,7 @@ class Iso20022Test { } // Load config - val cfg = nexusConfig(root.resolve("conf").resolve("${platform.fileName}.conf")) + val cfg = nexusConfig(root.resolve("platform").resolve("${platform.fileName}.conf")) val currency = cfg.currency val dialect = cfg.ebics.dialect // Parse logs