commit 0baa2eefdae2194949d017bc120b775bc3b87829
parent ec2ff01cfae3cc332fbf64ec5d77400b4357a50c
Author: Antoine A <>
Date: Tue, 15 Jul 2025 17:30:01 +0200
nexus: improve interrupted transaction closure logic
Diffstat:
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