libeufin

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

commit 0baa2eefdae2194949d017bc120b775bc3b87829
parent ec2ff01cfae3cc332fbf64ec5d77400b4357a50c
Author: Antoine A <>
Date:   Tue, 15 Jul 2025 17:30:01 +0200

nexus: improve interrupted transaction closure logic

Diffstat:
Mnexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt | 14++++++++++----
Mnexus/src/test/kotlin/EbicsTest.kt | 144++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
2 files changed, 124 insertions(+), 34 deletions(-)

diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt @@ -168,17 +168,23 @@ class EbicsClient( val txLog = ebicsLogger.tx(order) val impl = EbicsBTS(cfg.ebics, bankKeys, clientKeys, order) - // Close pending + // Close interrupted while (true) { val tId = db.ebics.first() if (tId == null) break val xml = impl.downloadReceipt(tId, false) try { - impl.postBTS(client, xml, "Closing pending") + impl.postBTS(client, xml, "Closing interrupted transaction ${tId}") } catch (e: Exception) { - if (e !is EbicsError.Code || e.technicalCode != EbicsReturnCode.EBICS_TX_UNKNOWN_TXID) { - throw e + when (e) { + // Transaction already closed or expired - EBICS protocol error + is EbicsError.Code if e.technicalCode == EbicsReturnCode.EBICS_TX_UNKNOWN_TXID -> {} + // Transaction already closed or expired - HTTP protocol error for non compliant banks + is EbicsError.HTTP if e.status == HttpStatusCode.BadRequest -> {} + // Unexpected error + else -> throw e } + logger.debug("${e.fmt()}") } db.ebics.remove(tId) } diff --git a/nexus/src/test/kotlin/EbicsTest.kt b/nexus/src/test/kotlin/EbicsTest.kt @@ -18,6 +18,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 io.ktor.http.* @@ -34,6 +35,8 @@ 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) @@ -90,7 +93,7 @@ class EbicsState { return XMLUtil.convertDomToBytes(doc) } - private fun ebicsResponsePayload(payload: ByteArray): ByteArray { + private fun ebicsResponsePayload(payload: ByteArray, last: Boolean = true): ByteArray { transactionId = randEbicsId() val deflated = payload.inputStream().deflate() val (transactionKey, encryptedTransactionKey) = CryptoUtil.genEbicsE002Key(clientEncrPub!!) @@ -106,7 +109,7 @@ class EbicsState { el("mutable") { el("TransactionPhase", "Initialisation") el("SegmentNumber") { - attr("lastSegment", "true") + attr("lastSegment", last.toString()) text("1") } el("ReturnCode", "000000") @@ -256,7 +259,7 @@ class EbicsState { } } - fun receipt(body: String): ByteArray { + private fun receipt(body: String, ok: Boolean): ByteArray { XmlDestructor.parse(body, "ebicsRequest") { one("header") { val id = one("static").one("TransactionID").text() @@ -265,7 +268,7 @@ class EbicsState { assertEquals(phase, "Receipt") } val code = one("body").one("TransferReceipt").one("ReceiptCode").text() - assertEquals(code, "0") + 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") @@ -292,6 +295,9 @@ class EbicsState { 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") { @@ -370,6 +376,26 @@ class EbicsState { 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 } +} + +private fun CliktCommand.succeed(cmd: String) { + val result = test(cmd) + require(result.statusCode == 0) { result } } @OptIn(kotlin.io.path.ExperimentalPathApi::class) @@ -383,8 +409,13 @@ class EbicsTest { val cfg: MockEngineConfig = MockEngineConfig() cfg.addHandler { req -> val body = String((req.body as OutgoingContent.ByteArrayContent).bytes()) - val res = steps.next()(body) - respond(res) + val handler = steps.next() + try { + val res = handler(body) + respond(res) + } catch (e: BadRequest) { + respondBadRequest() + } } MOCK_ENGINE = MockEngine(cfg) } @@ -402,12 +433,11 @@ class EbicsTest { yield(bank::hia) yield(bank::hpb) yield(bank::hkd) - yield(bank::receipt) + yield(bank::receiptOk) }) // Run setup - val res = nexusCmd.test("ebics-setup $args --auto-accept-keys") - assertEquals(res.statusCode, 0) + nexusCmd.succeed("ebics-setup $args --auto-accept-keys") } // POSTs an EBICS message to the mock bank. Tests @@ -471,66 +501,120 @@ class EbicsTest { // Default transient setMock(sequence { yield(bank::haa) - yield(bank::receipt) + yield(bank::receiptOk) yield(bank::btdNoData) }) - val transient = nexusCmd.test("ebics-fetch $args --transient") - assertEquals(transient.statusCode, 0) + nexusCmd.succeed("ebics-fetch $args --transient") // Pinned transient setMock(sequence { yield(bank::haa) - yield(bank::receipt) + yield(bank::receiptOk) yield(bank::btdNoDataPinned) }) - val transientPinned = nexusCmd.test("ebics-fetch $args --transient --pinned-start 2024-06-05") - assertEquals(transientPinned.statusCode, 0) + nexusCmd.succeed("ebics-fetch $args --transient --pinned-start 2024-06-05") // Init checkpoint setMock(sequence { yield(bank::hkd) - yield(bank::receipt) + yield(bank::receiptOk) yield(bank::btdNoData) }) - val initCheckpoint = nexusCmd.test("ebics-fetch $args --transient --checkpoint") - assertEquals(initCheckpoint.statusCode, 0) + nexusCmd.succeed("ebics-fetch $args --transient --checkpoint") // Default checkpoint setMock(sequence { yield(bank::hkd) - yield(bank::receipt) + yield(bank::receiptOk) yield(bank::btdNoDataNow) }) - val checkpoint = nexusCmd.test("ebics-fetch $args --transient --checkpoint") - assertEquals(checkpoint.statusCode, 0) + nexusCmd.succeed("ebics-fetch $args --transient --checkpoint") // Pinned checkpoint setMock(sequence { yield(bank::hkd) - yield(bank::receipt) + yield(bank::receiptOk) yield(bank::btdNoDataPinned) }) - val transientCheckpoint = nexusCmd.test("ebics-fetch $args --transient --checkpoint --pinned-start 2024-06-05") - assertEquals(transientCheckpoint.statusCode, 0) + nexusCmd.succeed("ebics-fetch $args --transient --checkpoint --pinned-start 2024-06-05") // Reset checkpoint resetCheckpoint() setMock(sequence { yield(bank::hkd) - yield(bank::receipt) + yield(bank::receiptOk) yield(bank::btdNoData) }) - val resetCheckpoint = nexusCmd.test("ebics-fetch $args --transient --checkpoint") - assertEquals(resetCheckpoint.statusCode, 0) + nexusCmd.succeed("ebics-fetch $args --transient --checkpoint") // Reset checkpoint pinned resetCheckpoint() setMock(sequence { yield(bank::hkd) - yield(bank::receipt) + yield(bank::receiptOk) yield(bank::btdNoDataPinned) }) - val resetCheckpointPinned = nexusCmd.test("ebics-fetch $args --transient --checkpoint --pinned-start 2024-06-05") - assertEquals(resetCheckpointPinned.statusCode, 0) + nexusCmd.succeed("ebics-fetch $args --transient --checkpoint --pinned-start 2024-06-05") + } + + @Test + fun closePendingTransaction() = setup { db, _ -> + ebicsSetup() + + // Failure before first segment + setMock(sequence { + // Failure to perform download + yield(bank::failure) + // Then continue + yield(bank::haa) + yield(bank::receiptOk) + yield(bank::btdNoData) + }) + nexusCmd.fail("ebics-fetch $args --transient") + nexusCmd.succeed("ebics-fetch $args --transient") + + // Compliant server + setMock(sequence { + yield(bank::haa) + yield(bank::receiptOk) + // Failure to perform download + yield(bank::initializeTx) + yield(bank::failure) + // Retry fail once + yield(bank::failure) + // Retry fail twice + yield(bank::failure) + // Retry succeed + yield(bank::receiptErr) + // Then continue + yield(bank::haa) + yield(bank::receiptOk) + yield(bank::btdNoData) + }) + nexusCmd.fail("ebics-fetch $args --transient") + nexusCmd.fail("ebics-fetch $args --transient") + nexusCmd.fail("ebics-fetch $args --transient") + nexusCmd.succeed("ebics-fetch $args --transient") + + // Non compliant server + setMock(sequence { + yield(bank::haa) + yield(bank::receiptOk) + // Failure to perform download + yield(bank::initializeTx) + yield(bank::badRequest) + // Retry fail + yield(bank::failure) + + // Retry succeed + yield(bank::badRequest) + // Then continue + yield(bank::haa) + yield(bank::receiptOk) + yield(bank::btdNoData) + }) + nexusCmd.fail("ebics-fetch $args --transient") + nexusCmd.fail("ebics-fetch $args --transient") + nexusCmd.succeed("ebics-fetch $args --transient") } } \ No newline at end of file