libeufin

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

commit e3b169ad726e745133b111487c8d58b825261c62
parent 7a364baa832a0373d2870ab9254a776f2076e77c
Author: Antoine A <>
Date:   Tue, 31 Mar 2026 13:56:03 +0200

common: add new testing wg endpoint and fixes

Diffstat:
Mdatabase-versioning/libeufin-bank-procedures.sql | 13+++++++------
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt | 20+++++++++++++++++---
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/db/GcDAO.kt | 6++++--
Rlibeufin-bank/src/test/kotlin/WireTransferApiTest.kt -> libeufin-bank/src/test/kotlin/PreparedApiTest.kt | 0
Mlibeufin-bank/src/test/kotlin/WireGatewayApiTest.kt | 59+++++++++++++++++++++++++++++++++++++++++++++++------------
Mlibeufin-bank/src/test/kotlin/helpers.kt | 2+-
Mlibeufin-common/src/main/kotlin/TalerErrorCode.kt | 33+++++++++++++++++++++++++++++++++
Mlibeufin-common/src/main/kotlin/TalerMessage.kt | 8++++++++
Mlibeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt | 19+++++++++++++++++--
Mlibeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt | 59+++++++++++++++++++++++++++++++++++++++++++++++------------
10 files changed, 181 insertions(+), 38 deletions(-)

diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -1085,11 +1085,15 @@ END IF; SELECT out_balance_insufficient, out_credit_row_id, - transfer.out_reserve_pub_reuse + t.out_reserve_pub_reuse, + t.out_mapping_reuse, + t.out_unknown_mapping INTO out_debitor_balance_insufficient, out_tx_row_id, - out_reserve_pub_reuse + out_reserve_pub_reuse, + out_mapping_reuse, + out_unknown_mapping FROM make_incoming( exchange_bank_account_id, sender_bank_account_id, @@ -1101,10 +1105,7 @@ SELECT NULL, NULL, NULL - ) as transfer; -IF out_debitor_balance_insufficient THEN - RETURN; -END IF; + ) as t; END $$; COMMENT ON FUNCTION taler_add_incoming IS 'Create an incoming taler transaction and register it'; diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt @@ -200,9 +200,14 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { "Insufficient balance for debitor", TalerErrorCode.BANK_UNALLOWED_DEBIT ) - - AddIncomingResult.UnknownMapping, AddIncomingResult.MappingReuse -> throw UnsupportedOperationException("no mapping used") - + AddIncomingResult.MappingReuse -> throw conflict( + "authorization_pub used already", + TalerErrorCode.BANK_TRANSFER_MAPPING_REUSED + ) + AddIncomingResult.UnknownMapping -> throw conflict( + "authorization_pub unknown", + TalerErrorCode.BANK_TRANSFER_MAPPING_UNKNOWN + ) is AddIncomingResult.Success -> this.respond( AddIncomingResponse( timestamp = TalerTimestamp(timestamp), @@ -229,5 +234,14 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { metadata = IncomingSubject.Kyc(req.account_pub) ) } + post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-mapped") { + val req = call.receive<AddMappedRequest>() + call.addIncoming( + amount = req.amount, + debitAccount = req.debit_account, + subject = "Manual incoming MAP:${req.authorization_pub}", + metadata = IncomingSubject.Map(req.authorization_pub) + ) + } } } diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/GcDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/GcDAO.kt @@ -41,7 +41,7 @@ class GcDAO(private val db: Database) { conn.withStatement( """ UPDATE taler_withdrawal_operations SET aborted = true WHERE creation_date < ? AND NOT EXISTS( - SELECT FROM prepared_transfers WHERE prepared_transfers.withdrawal_id=taler_withdrawal_operations.withdrawal_id + SELECT FROM prepared_transfers JOIN taler_withdrawal_operations USING (withdrawal_id) ) """ ) { @@ -51,7 +51,9 @@ class GcDAO(private val db: Database) { // Clean aborted operations, expired challenges and expired tokens for (smt in listOf( - "DELETE FROM taler_withdrawal_operations WHERE aborted = true AND creation_date < ?", + """DELETE FROM taler_withdrawal_operations WHERE aborted = true AND creation_date < ? AND NOT EXISTS( + SELECT FROM prepared_transfers JOIN taler_withdrawal_operations USING (withdrawal_id) + )""", "DELETE FROM tan_challenges WHERE expiration_date < ?", "DELETE FROM bearer_tokens WHERE expiration_time < ?" )) { diff --git a/libeufin-bank/src/test/kotlin/WireTransferApiTest.kt b/libeufin-bank/src/test/kotlin/PreparedApiTest.kt diff --git a/libeufin-bank/src/test/kotlin/WireGatewayApiTest.kt b/libeufin-bank/src/test/kotlin/WireGatewayApiTest.kt @@ -22,6 +22,7 @@ import io.ktor.server.testing.* import io.ktor.client.request.* import org.junit.Test import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.common.test.* import kotlin.test.* @@ -391,11 +392,24 @@ class WireGatewayApiTest { val (path, key) = when (type) { IncomingType.reserve -> Pair("add-incoming", "reserve_pub") IncomingType.kyc -> Pair("add-kycauth", "account_pub") - IncomingType.map -> throw UnsupportedOperationException() + IncomingType.map -> Pair("add-mapped", "authorization_pub") } + + val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() + client.post("/accounts/exchange/taler-prepared-transfer/registration") { + json { + "credit_amount" to "KUDOS:44" + "type" to "reserve" + "alg" to "EdDSA" + "account_pub" to pub + "authorization_pub" to pub + "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) + "recurrent" to false + } + }.assertOkJson<SubjectResult>() val valid_req = obj { "amount" to "KUDOS:44" - key to EddsaPublicKey.randEdsaKey() + key to pub "debit_account" to merchantPayto.canonical } @@ -412,16 +426,31 @@ class WireGatewayApiTest { json(valid_req) }.assertOk() - if (type == IncomingType.reserve) { - // Trigger conflict due to reused reserve_pub - client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { - json(valid_req) - }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) - } else if (type == IncomingType.kyc) { - // Non conflict on reuse - client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { - json(valid_req) - }.assertOk() + when (type) { + IncomingType.reserve -> { + // Trigger conflict due to reused reserve_pub + client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { + json(valid_req) + }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) + } + IncomingType.kyc -> { + // Non conflict on reuse + client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { + json(valid_req) + }.assertOk() + } + IncomingType.map -> { + // Trigger conflict due to reused authorization_pub + client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { + json(valid_req) + }.assertConflict(TalerErrorCode.BANK_TRANSFER_MAPPING_REUSED) + // Trigger conflict due to unknown authorization_pub + client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { + json(valid_req) { + key to EddsaPublicKey.randEdsaKey() + } + }.assertConflict(TalerErrorCode.BANK_TRANSFER_MAPPING_UNKNOWN) + } } // Currency mismatch @@ -472,6 +501,12 @@ class WireGatewayApiTest { talerAddIncomingRoutine(IncomingType.kyc) } + // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-mapped + @Test + fun addMapped() = bankSetup { + talerAddIncomingRoutine(IncomingType.map) + } + @Test fun addIncomingMix() = bankSetup { addIncoming("KUDOS:1") diff --git a/libeufin-bank/src/test/kotlin/helpers.kt b/libeufin-bank/src/test/kotlin/helpers.kt @@ -66,8 +66,8 @@ fun setup( globalTestTokens.clear() val cfg = bankConfig(Path("conf/$conf")) pgDataSource(cfg.dbCfg.dbConnStr).run { - dbInit(cfg.dbCfg, "libeufin-nexus", true) dbInit(cfg.dbCfg, "libeufin-bank", true) + dbInit(cfg.dbCfg, "libeufin-nexus", true) } cfg.withDb { db, cfg -> db.conn { conn -> diff --git a/libeufin-common/src/main/kotlin/TalerErrorCode.kt b/libeufin-common/src/main/kotlin/TalerErrorCode.kt @@ -96,6 +96,9 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The body is too large to be permissible for the endpoint. If you believe this was a legitimate request, contact the server administrators and/or the software developers to increase the limit. */ GENERIC_UPLOAD_EXCEEDS_LIMIT(32, 413, "The body is too large to be permissible for the endpoint. If you believe this was a legitimate request, contact the server administrators and/or the software developers to increase the limit."), + /** A parameter in the request was given that must not be present. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers. */ + GENERIC_PARAMETER_EXTRA(33, 400, "A parameter in the request was given that must not be present. This is likely a bug in the client implementation. Check if you are using the latest available version and/or file a report with the developers."), + /** The service refused the request due to lack of proper authorization. Accessing this endpoint requires an access token from the account owner. */ GENERIC_UNAUTHORIZED(40, 401, "The service refused the request due to lack of proper authorization. Accessing this endpoint requires an access token from the account owner."), @@ -1032,6 +1035,15 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The merchant does not expect any transfer with the given ID and can thus not return any details about it. */ MERCHANT_GENERIC_EXPECTED_TRANSFER_UNKNOWN(2040, 404, "The merchant does not expect any transfer with the given ID and can thus not return any details about it."), + /** The Donau is not known to the backend. */ + MERCHANT_GENERIC_DONAU_UNKNOWN(2041, 404, "The Donau is not known to the backend."), + + /** The access token is not known to the backend. */ + MERCHANT_GENERIC_ACCESS_TOKEN_UNKNOWN(2042, 404, "The access token is not known to the backend."), + + /** One of the binaries needed to generate the PDF is not installed. If this feature is required, the system administrator should make sure Typst and pdftk are both installed. */ + MERCHANT_GENERIC_NO_TYPST_OR_PDFTK(2048, 501, "One of the binaries needed to generate the PDF is not installed. If this feature is required, the system administrator should make sure Typst and pdftk are both installed."), + /** The exchange failed to provide a valid answer to the tracking request, thus those details are not in the response. */ MERCHANT_GET_ORDERS_EXCHANGE_TRACKING_FAILURE(2100, 200, "The exchange failed to provide a valid answer to the tracking request, thus those details are not in the response."), @@ -1230,6 +1242,15 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The response from the exchange was unacceptable and should be reviewed with an auditor. */ MERCHANT_EXCHANGE_TRANSFERS_HARD_FAILURE(2264, 200, "The response from the exchange was unacceptable and should be reviewed with an auditor."), + /** The merchant backend failed to reach the banking gateway to shorten the wire transfer subject. This probably means that the banking gateway of the exchange is currently down. Contact the exchange operator or simply retry again later. */ + MERCHANT_POST_ACCOUNTS_KYCAUTH_BANK_GATEWAY_UNREACHABLE(2275, 502, "The merchant backend failed to reach the banking gateway to shorten the wire transfer subject. This probably means that the banking gateway of the exchange is currently down. Contact the exchange operator or simply retry again later."), + + /** The merchant backend failed to reach the banking gateway to shorten the wire transfer subject. This probably means that the banking gateway of the exchange is currently down. Contact the exchange operator or simply retry again later. */ + MERCHANT_POST_ACCOUNTS_EXCHANGE_TOO_OLD(2276, 502, "The merchant backend failed to reach the banking gateway to shorten the wire transfer subject. This probably means that the banking gateway of the exchange is currently down. Contact the exchange operator or simply retry again later."), + + /** The merchant backend failed to reach the specified exchange. This probably means that the exchange is currently down. Contact the exchange operator or simply retry again later. */ + MERCHANT_POST_ACCOUNTS_KYCAUTH_EXCHANGE_UNREACHABLE(2277, 502, "The merchant backend failed to reach the specified exchange. This probably means that the exchange is currently down. Contact the exchange operator or simply retry again later."), + /** We could not claim the order because the backend is unaware of it. */ MERCHANT_POST_ORDERS_ID_CLAIM_NOT_FOUND(2300, 404, "We could not claim the order because the backend is unaware of it."), @@ -1239,6 +1260,9 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The client-side experienced an internal failure. */ MERCHANT_POST_ORDERS_ID_CLAIM_CLIENT_INTERNAL_FAILURE(2302, 0, "The client-side experienced an internal failure."), + /** The unclaim signature of the wallet is not valid for the given contract hash. */ + MERCHANT_POST_ORDERS_UNCLAIM_SIGNATURE_INVALID(2303, 403, "The unclaim signature of the wallet is not valid for the given contract hash."), + /** The backend failed to sign the refund request. */ MERCHANT_POST_ORDERS_ID_REFUND_SIGNATURE_FAILED(2350, 0, "The backend failed to sign the refund request."), @@ -1311,6 +1335,9 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The order provided to the backend could not be deleted as the order was already paid. */ MERCHANT_PRIVATE_DELETE_ORDERS_ALREADY_PAID(2521, 409, "The order provided to the backend could not be deleted as the order was already paid."), + /** The client requested a report granularity that is not available at the backend. Possible solutions include extending the backend code and/or the database statistic triggers to support the desired data granularity. Alternatively, the client could request a different granularity. */ + MERCHANT_PRIVATE_GET_STATISTICS_REPORT_GRANULARITY_UNAVAILABLE(2525, 410, "The client requested a report granularity that is not available at the backend. Possible solutions include extending the backend code and/or the database statistic triggers to support the desired data granularity. Alternatively, the client could request a different granularity."), + /** The amount to be refunded is inconsistent: either is lower than the previous amount being awarded, or it exceeds the original price paid by the customer. */ MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_INCONSISTENT_AMOUNT(2530, 409, "The amount to be refunded is inconsistent: either is lower than the previous amount being awarded, or it exceeds the original price paid by the customer."), @@ -1674,6 +1701,12 @@ enum class TalerErrorCode(val code: Int, val status: Int, val description: Strin /** The provided timestamp is too old. */ BANK_OLD_TIMESTAMP(5161, 409, "The provided timestamp is too old."), + /** The authorization_pub for a request to transfer funds has already been used for another non recurrent transfer. */ + BANK_TRANSFER_MAPPING_REUSED(5162, 409, "The authorization_pub for a request to transfer funds has already been used for another non recurrent transfer."), + + /** The authorization_pub for a request to transfer funds is not currenlty registered. */ + BANK_TRANSFER_MAPPING_UNKNOWN(5163, 409, "The authorization_pub for a request to transfer funds is not currenlty registered."), + /** The sync service failed find the account in its database. */ SYNC_ACCOUNT_UNKNOWN(6100, 404, "The sync service failed find the account in its database."), diff --git a/libeufin-common/src/main/kotlin/TalerMessage.kt b/libeufin-common/src/main/kotlin/TalerMessage.kt @@ -124,6 +124,14 @@ data class AddKycauthRequest( val debit_account: Payto ) +/** Request POST /taler-wire-gateway/admin/add-mapped */ +@Serializable +data class AddMappedRequest( + val amount: TalerAmount, + val authorization_pub: EddsaPublicKey, + val debit_account: Payto +) + /** Response GET /taler-wire-gateway/history/incoming */ @Serializable data class IncomingHistory( diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt @@ -138,8 +138,14 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir "reserve_pub used already", TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT ) - IncomingRegistrationResult.MappingReuse, - IncomingRegistrationResult.UnknownMapping -> throw UnsupportedOperationException("mapping not used") + IncomingRegistrationResult.MappingReuse -> throw conflict( + "authorization_pub used already", + TalerErrorCode.BANK_TRANSFER_MAPPING_REUSED + ) + IncomingRegistrationResult.UnknownMapping -> throw conflict( + "authorization_pub unknown", + TalerErrorCode.BANK_TRANSFER_MAPPING_UNKNOWN + ) is IncomingRegistrationResult.Success -> respond( AddIncomingResponse( timestamp = TalerTimestamp(timestamp), @@ -166,5 +172,14 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir metadata = IncomingSubject.Kyc(req.account_pub) ) } + post("/taler-wire-gateway/admin/add-mapped") { + val req = call.receive<AddMappedRequest>() + call.addIncoming( + amount = req.amount, + debitAccount = req.debit_account, + subject = "Manual incoming MAP:${req.authorization_pub}", + metadata = IncomingSubject.Map(req.authorization_pub) + ) + } } } diff --git a/libeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt b/libeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -22,6 +22,7 @@ import io.ktor.http.* import io.ktor.server.testing.* import org.junit.Test import tech.libeufin.common.* +import tech.libeufin.common.crypto.CryptoUtil import tech.libeufin.nexus.cli.registerOutgoingPayment import tech.libeufin.ebics.randEbicsId import java.time.Instant @@ -334,11 +335,24 @@ class WireGatewayApiTest { val (path, key) = when (type) { IncomingType.reserve -> Pair("add-incoming", "reserve_pub") IncomingType.kyc -> Pair("add-kycauth", "account_pub") - IncomingType.map -> throw UnsupportedOperationException() + IncomingType.map -> Pair("add-mapped", "authorization_pub") } + + val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() + client.post("/taler-prepared-transfer/registration") { + json { + "credit_amount" to "CHF:44" + "type" to "reserve" + "alg" to "EdDSA" + "account_pub" to pub + "authorization_pub" to pub + "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) + "recurrent" to false + } + }.assertOkJson<SubjectResult>() val valid_req = obj { "amount" to "CHF:44" - key to EddsaPublicKey.randEdsaKey() + key to pub "debit_account" to grothoffPayto } @@ -349,16 +363,31 @@ class WireGatewayApiTest { json(valid_req) }.assertOk() - if (type == IncomingType.reserve) { - // Trigger conflict due to reused reserve_pub - client.postA("/taler-wire-gateway/admin/$path") { - json(valid_req) - }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) - } else if (type == IncomingType.kyc) { - // Non conflict on reuse - client.postA("/taler-wire-gateway/admin/$path") { - json(valid_req) - }.assertOk() + when (type) { + IncomingType.reserve -> { + // Trigger conflict due to reused reserve_pub + client.postA("/taler-wire-gateway/admin/$path") { + json(valid_req) + }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) + } + IncomingType.kyc -> { + // Non conflict on reuse + client.postA("/taler-wire-gateway/admin/$path") { + json(valid_req) + }.assertOk() + } + IncomingType.map -> { + // Trigger conflict due to reused authorization_pub + client.postA("/taler-wire-gateway/admin/$path") { + json(valid_req) + }.assertConflict(TalerErrorCode.BANK_TRANSFER_MAPPING_REUSED) + // Trigger conflict due to unknown authorization_pub + client.postA("/taler-wire-gateway/admin/$path") { + json(valid_req) { + key to EddsaPublicKey.randEdsaKey() + } + }.assertConflict(TalerErrorCode.BANK_TRANSFER_MAPPING_UNKNOWN) + } } // Currency mismatch @@ -400,6 +429,12 @@ class WireGatewayApiTest { talerAddIncomingRoutine(IncomingType.kyc) } + // POST /taler-wire-gateway/admin/add-mapped + @Test + fun addMapped() = serverSetup { + talerAddIncomingRoutine(IncomingType.map) + } + @Test fun addIncomingMix() = serverSetup { db -> addIncoming("CHF:1")