commit 902cdf300456c929c0b1f5350f5e8147d94de538
parent 605aeff268fa30e66bb15c79fdccb9c325e2bb25
Author: Antoine A <>
Date: Mon, 8 Jul 2024 13:57:58 +0200
nexus: study EBICS transaction semantics, fix cli error format and improve EBICS code
Diffstat:
7 files changed, 138 insertions(+), 19 deletions(-)
diff --git a/common/src/main/kotlin/Cli.kt b/common/src/main/kotlin/Cli.kt
@@ -40,7 +40,7 @@ import org.slf4j.event.Level
private val logger: Logger = LoggerFactory.getLogger("libeufin-config")
fun Throwable.fmt(): String = buildString {
- append(message ?: this::class.simpleName)
+ append(message ?: this@fmt::class.simpleName)
var cause = cause
while (cause != null) {
append(": ")
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt
@@ -61,6 +61,7 @@ data class SubmissionContext(
*
* @param ctx [SubmissionContext]
* @return true on success, false otherwise.
+ * TODO update doc
*/
private suspend fun submitInitiatedPayment(
ctx: SubmissionContext,
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt
@@ -232,6 +232,25 @@ class FakeIncoming: CliktCommand("Genere a fake incoming payment") {
}
}
+class TxCheck: CliktCommand("Check transaction semantic") {
+ private val common by CommonOption()
+
+ override fun run() = cliCmd(logger, common.log) {
+ val cfg = loadNexusConfig(common.config)
+ val (clientKeys, bankKeys) = expectFullKeys(cfg)
+ val doc = EbicsDocument.acknowledgement.doc()
+ val order = cfg.dialect.downloadDoc(doc, false)
+ val client = HttpClient {
+ install(HttpTimeout) {
+ // It can take a lot of time for the bank to generate documents
+ socketTimeoutMillis = 5 * 60 * 1000
+ }
+ }
+ val result = tech.libeufin.nexus.test.txCheck(client, cfg, clientKeys, bankKeys, order, cfg.dialect.directDebit())
+ println("$result")
+ }
+}
+
enum class ListKind {
incoming,
outgoing,
@@ -397,7 +416,7 @@ class ListCmd: CliktCommand("List nexus transactions", name = "list") {
class TestingCmd : CliktCommand("Testing helper commands", name = "testing") {
init {
- subcommands(FakeIncoming(), ListCmd(), EbicsDownload())
+ subcommands(FakeIncoming(), ListCmd(), EbicsDownload(), TxCheck())
}
override fun run() = Unit
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt
@@ -139,11 +139,13 @@ suspend fun EbicsBTS.postBTS(
)) {
throw EbicsError.Protocol("$phase: bank signature did not verify")
}
- try {
- return EbicsBTS.parseResponse(doc)
+ val response = try {
+ EbicsBTS.parseResponse(doc)
} catch (e: Exception) {
throw EbicsError.Protocol("$phase: invalid ebics response", e)
}
+ logger.debug("{} return codes: {} & {}", phase, response.technicalCode, response.bankCode)
+ return response
}
/**
@@ -167,7 +169,7 @@ suspend fun ebicsDownload(
clientKeys: ClientPrivateKeysFile,
bankKeys: BankPublicKeysFile,
order: EbicsOrder,
- startDate: Instant?,
+ startDate: Instant?,
endDate: Instant?,
processing: suspend (InputStream) -> Unit,
) = coroutineScope {
@@ -303,17 +305,18 @@ fun prepareUploadPayload(
innerSignedEbicsXml.inputStream().deflate()
).encodeBase64()
// Compress and encrypt payload
- val segment = CryptoUtil.encryptEbicsE002(
+ val encrypted = CryptoUtil.encryptEbicsE002(
transactionKey,
payload.inputStream().deflate()
- ).encodeBase64()
- // TODO split 1MB segment when we have payloads that big
+ )
+ // Chunks of 1MB and encode segments
+ val segments = encrypted.encodeBase64().chunked(1000000)
return PreparedUploadData(
encryptedTransactionKey,
orderSignature,
payloadDigest,
- listOf(segment)
+ segments
)
}
@@ -336,9 +339,8 @@ suspend fun doEbicsUpload(
bankKeys: BankPublicKeysFile,
order: EbicsOrder,
payload: ByteArray,
-): String = withContext(NonCancellable) {
+): String {
val impl = EbicsBTS(cfg, bankKeys, clientKeys, order)
- // TODO use a lambda and pass the order detail there for atomicity ?
val preparedPayload = prepareUploadPayload(cfg, clientKeys, bankKeys, payload)
// Init phase
@@ -350,13 +352,13 @@ suspend fun doEbicsUpload(
val orderId = requireNotNull(initResp.orderID) {
"Upload init phase: missing order ID"
}
-
+
// Transfer phase
for (i in 1..preparedPayload.segments.size) {
val transferXml = impl.uploadTransfer(tId, preparedPayload, i)
val transferResp = impl.postBTS(client, transferXml, "Upload transfer phase").okOrFail("Upload transfer phase")
}
- orderId
+ return orderId
}
private val SECURE_RNG = SecureRandom()
@@ -385,9 +387,18 @@ class EbicsResponse<T>(
val bankCode: EbicsReturnCode,
private val content: T
) {
+ /** Checks that return codes are both EBICS_OK */
+ fun ok(): T? {
+ return if (technicalCode.kind() != EbicsReturnCode.Kind.Error &&
+ bankCode.kind() != EbicsReturnCode.Kind.Error) {
+ content
+ } else {
+ null
+ }
+ }
+
/** Checks that return codes are both EBICS_OK or throw an exception */
fun okOrFail(phase: String): T {
- logger.debug("{} return codes: {} & {}", phase, technicalCode, bankCode)
require(technicalCode.kind() != EbicsReturnCode.Kind.Error) {
"$phase has technical error: $technicalCode"
}
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsConstants.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsConstants.kt
@@ -44,6 +44,7 @@ enum class EbicsReturnCode(val code: String) {
EBICS_INVALID_XML("091010"),
EBICS_TX_MESSAGE_REPLAY("091103"),
EBICS_TX_SEGMENT_NUMBER_EXCEEDED("091104"),
+ EBICS_TX_UNKNOWN_TXID("091101"),
EBICS_INVALID_REQUEST_CONTENT("091113"),
EBICS_PROCESSING_ERROR("091116"),
diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/test/TxCheck.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/test/TxCheck.kt
@@ -0,0 +1,89 @@
+/*
+ * This file is part of LibEuFin.
+ * Copyright (C) 2024 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.nexus.test
+
+import io.ktor.client.*
+import tech.libeufin.common.*
+import tech.libeufin.nexus.*
+import tech.libeufin.nexus.ebics.*
+
+data class TxCheckResult(
+ var concurrentFetchAndFetch: Boolean = false,
+ var concurrentFetchAndSubmit: Boolean = false,
+ var concurrentSubmitAndSubmit: Boolean = false,
+ var idempotentClose: Boolean = false
+)
+
+/**
+ * Test EBICS implementation's transactions semantic:
+ * - Can two fetch transactions run concurently ?
+ * - Can a fetch & submit transactions run concurently ?
+ * - Can two submit transactions run concurently ?
+ * - Is closing a submit transaction idempotent
+ */
+suspend fun txCheck(
+ client: HttpClient,
+ cfg: NexusConfig,
+ clientKeys: ClientPrivateKeysFile,
+ bankKeys: BankPublicKeysFile,
+ fetchOrder: EbicsOrder,
+ submitOrder: EbicsOrder
+): TxCheckResult {
+ val result = TxCheckResult()
+ val fetch = EbicsBTS(cfg, bankKeys, clientKeys, fetchOrder)
+ val submit = EbicsBTS(cfg, bankKeys, clientKeys, submitOrder)
+
+ suspend fun EbicsBTS.close(id: String, phase: String) {
+ val xml = downloadReceipt(id, false)
+ postBTS(client, xml, phase).okOrFail(phase)
+ }
+
+ val firstTxId = fetch.postBTS(client, fetch.downloadInitialization(null, null), "Init first fetch")
+ .okOrFail("Init first fetch")
+ .transactionID!!
+ try {
+ fetch.postBTS(client, fetch.downloadInitialization(null, null), "Init second fetch").ok()?.run {
+ result.concurrentFetchAndFetch = true
+ fetch.close(transactionID!!, "Init second fetch")
+ }
+ var paylod = prepareUploadPayload(cfg, clientKeys, bankKeys, ByteArray(2000000).rand())
+ val submitId = submit.postBTS(client, submit.uploadInitialization(paylod), "Init first submit").ok()?.run {
+ result.concurrentFetchAndSubmit = true
+ transactionID!!
+ }
+ if (submitId != null) {
+ submit.postBTS(client, submit.uploadTransfer(submitId, paylod, 1), "Submit upload").okOrFail("Submit first upload")
+ submit.postBTS(client, submit.uploadInitialization(paylod), "Init second submit").ok()?.run {
+ result.concurrentSubmitAndSubmit = true
+ }
+ }
+ } finally {
+ fetch.close(firstTxId, "Close first fetch")
+ }
+
+ try {
+ fetch.close(firstTxId, "Close first fetch a second time")
+ result.idempotentClose = true
+ } catch (e: Exception) {
+ logger.debug(e.fmt())
+ }
+
+ return result
+}
+\ No newline at end of file
diff --git a/testbench/src/main/kotlin/Main.kt b/testbench/src/main/kotlin/Main.kt
@@ -29,11 +29,7 @@ import io.ktor.client.*
import io.ktor.client.engine.cio.*
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
-import tech.libeufin.nexus.LibeufinNexusCommand
-import tech.libeufin.nexus.loadBankKeys
-import tech.libeufin.nexus.loadClientKeys
-import tech.libeufin.nexus.loadConfig
-import tech.libeufin.nexus.loadJsonFile
+import tech.libeufin.nexus.*
import tech.libeufin.common.ANSI
import kotlin.io.path.*
@@ -213,6 +209,7 @@ class Cli : CliktCommand("Run integration tests on banks provider") {
nexusCmd.run("ebics-submit $ebicsFlags")
Unit
})
+ put("tx-check", "Check transaction semantic", "testing tx-check $flags")
}
while (true) {
var clientKeys = loadClientKeys(clientKeysPath)