libeufin

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

WireGatewayApiTest.kt (17850B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A.
      4 
      5  * LibEuFin is free software; you can redistribute it and/or modify
      6  * it under the terms of the GNU Affero General Public License as
      7  * published by the Free Software Foundation; either version 3, or
      8  * (at your option) any later version.
      9 
     10  * LibEuFin is distributed in the hope that it will be useful, but
     11  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12  * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13  * Public License for more details.
     14 
     15  * You should have received a copy of the GNU Affero General Public
     16  * License along with LibEuFin; see the file COPYING.  If not, see
     17  * <http://www.gnu.org/licenses/>
     18  */
     19 
     20 import io.ktor.client.request.*
     21 import io.ktor.http.*
     22 import io.ktor.server.testing.*
     23 import org.junit.Test
     24 import tech.libeufin.common.*
     25 import tech.libeufin.common.crypto.CryptoUtil
     26 import tech.libeufin.nexus.cli.registerOutgoingPayment
     27 import tech.libeufin.ebics.randEbicsId
     28 import java.time.Instant
     29 import kotlin.test.*
     30 
     31 class WireGatewayApiTest {
     32     // GET /taler-wire-gateway/config
     33     @Test
     34     fun config() = serverSetup {
     35         client.get("/taler-wire-gateway/config").assertOk()
     36     }
     37 
     38     // POST /taler-wire-gateway/transfer
     39     @Test
     40     fun transfer() = serverSetup { 
     41         val valid_req = obj {
     42             "request_uid" to HashCode.rand()
     43             "amount" to "CHF:55"
     44             "exchange_base_url" to "http://exchange.example.com/"
     45             "wtid" to ShortHashCode.rand()
     46             "credit_account" to grothoffPayto
     47         }
     48 
     49         authRoutine(HttpMethod.Post, "/taler-wire-gateway/transfer")
     50 
     51         // Check OK
     52         client.postA("/taler-wire-gateway/transfer") {
     53             json(valid_req)
     54         }.assertOk()
     55 
     56         // check idempotency
     57         client.postA("/taler-wire-gateway/transfer") {
     58             json(valid_req)
     59         }.assertOk()
     60         
     61         val with_metadata = obj(valid_req) {
     62             "request_uid" to HashCode.rand()
     63             "metadata" to "ID"
     64             "wtid" to ShortHashCode.rand()
     65         }
     66         client.postA("/taler-wire-gateway/transfer") {
     67             json(with_metadata)
     68         }.assertOk()
     69         client.postA("/taler-wire-gateway/transfer") {
     70             json(with_metadata)
     71         }.assertOk()
     72         
     73         // Malformed metadata
     74         listOf("bad_id", "bad id", "bad@id.com", "A".repeat(41)).forEach {
     75             client.postA("/taler-wire-gateway/transfer") {
     76                 json(valid_req) {
     77                     "request_uid" to HashCode.rand()
     78                     "metadata" to it
     79                     "wtid" to ShortHashCode.rand()
     80                 }
     81             }.assertBadRequest()
     82         }
     83 
     84         // Trigger conflict due to reused request_uid
     85         client.postA("/taler-wire-gateway/transfer") {
     86             json(valid_req) { 
     87                 "wtid" to ShortHashCode.rand()
     88                 "exchange_base_url" to "http://different-exchange.example.com/"
     89             }
     90         }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED)
     91 
     92         // Trigger conflict due to reused wtid
     93         client.postA("/taler-wire-gateway/transfer") {
     94             json(valid_req) { 
     95                 "request_uid" to HashCode.rand()
     96             }
     97         }.assertConflict(TalerErrorCode.BANK_TRANSFER_WTID_REUSED)
     98 
     99         // Currency mismatch
    100         client.postA("/taler-wire-gateway/transfer") {
    101             json(valid_req) {
    102                 "amount" to "EUR:33"
    103             }
    104         }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
    105 
    106         // Bad BASE32 wtid
    107         client.postA("/taler-wire-gateway/transfer") {
    108             json(valid_req) { 
    109                 "wtid" to "I love chocolate"
    110             }
    111         }.assertBadRequest()
    112         
    113         // Bad BASE32 len wtid
    114         client.postA("/taler-wire-gateway/transfer") {
    115             json(valid_req) { 
    116                 "wtid" to Base32Crockford.encode(ByteArray(31).rand())
    117             }
    118         }.assertBadRequest()
    119 
    120         // Bad BASE32 request_uid
    121         client.postA("/taler-wire-gateway/transfer") {
    122             json(valid_req) { 
    123                 "request_uid" to "I love chocolate"
    124             }
    125         }.assertBadRequest()
    126 
    127         // Bad BASE32 len wtid
    128         client.postA("/taler-wire-gateway/transfer") {
    129             json(valid_req) { 
    130                 "request_uid" to Base32Crockford.encode(ByteArray(65).rand())
    131             }
    132         }.assertBadRequest()
    133 
    134         // Missing receiver-name
    135         client.postA("/taler-wire-gateway/transfer") {
    136             json(valid_req) { 
    137                 "credit_account" to "payto://iban/CH7389144832588726658"
    138             }
    139         }.assertBadRequest()
    140 
    141         // Bad payto kind
    142         client.postA("/taler-wire-gateway/transfer") {
    143             json(valid_req) { 
    144                 "credit_account" to "payto://x-taler-bank/bank.hostname.test/bar?receiver-name=Mr+Tom"
    145             }
    146         }.assertBadRequest()
    147 
    148         // Bad baseURL
    149         for (bad in sequenceOf("not-a-url", "file://not.http.com/", "no.transport.com/", "https://not.a/base/url")) {
    150             client.postA("/taler-wire-gateway/transfer") {
    151                 json(valid_req) {
    152                     "exchange_base_url" to bad
    153                 }
    154             }.assertBadRequest()
    155         }
    156     }
    157 
    158     // GET /taler-wire-gateway/transfers/{ROW_ID}
    159     @Test
    160     fun transferById() = serverSetup { 
    161         val wtid = ShortHashCode.rand()
    162         val valid_req = obj {
    163             "request_uid" to HashCode.rand()
    164             "amount" to "CHF:55"
    165             "exchange_base_url" to "http://exchange.example.com/"
    166             "wtid" to wtid
    167             "credit_account" to grothoffPayto
    168         }
    169 
    170         authRoutine(HttpMethod.Get, "/taler-wire-gateway/transfers/1")
    171 
    172         val resp = client.postA("/taler-wire-gateway/transfer") {
    173             json(valid_req)
    174         }.assertOkJson<TransferResponse>()
    175 
    176         // Check OK
    177         client.getA("/taler-wire-gateway/transfers/${resp.row_id}")
    178             .assertOkJson<TransferStatus> { tx ->
    179             assertEquals(TransferStatusState.pending, tx.status)
    180             assertEquals(TalerAmount("CHF:55"), tx.amount)
    181             assertEquals("http://exchange.example.com/", tx.origin_exchange_url)
    182             assertNull(tx.metadata)
    183             assertEquals(wtid, tx.wtid)
    184             assertEquals(resp.timestamp, tx.timestamp)
    185         }
    186         
    187         client.postA("/taler-wire-gateway/transfer") {
    188             json(valid_req) {
    189                 "request_uid" to HashCode.rand()
    190                 "metadata" to "ID"
    191                 "wtid" to ShortHashCode.rand()
    192             }
    193         }.assertOkJson<TransferResponse> {
    194             client.getA("/taler-wire-gateway/transfers/${it.row_id}")
    195                 .assertOkJson<TransferStatus> { tx ->
    196                 assertEquals(tx.metadata, "ID")
    197             }
    198         }
    199 
    200         // Check unknown transaction
    201         client.getA("/taler-wire-gateway/transfers/42")
    202             .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
    203     }
    204 
    205     // GET /accounts/{USERNAME}/taler-wire-gateway/transfers
    206     @Test
    207     fun transferPage() = serverSetup { db ->
    208         authRoutine(HttpMethod.Get, "/taler-wire-gateway/transfers")
    209 
    210         client.getA("/taler-wire-gateway/transfers").assertNoContent()
    211 
    212         repeat(6) {
    213             client.postA("/taler-wire-gateway/transfer") {
    214                 json {
    215                     "request_uid" to HashCode.rand()
    216                     "amount" to "CHF:55"
    217                     "exchange_base_url" to "http://exchange.example.com/"
    218                     "wtid" to ShortHashCode.rand()
    219                     "credit_account" to grothoffPayto
    220                 }
    221             }.assertOkJson<TransferResponse>()
    222             db.initiated.batch(Instant.now(), randEbicsId(), false)
    223         }
    224         client.getA("/taler-wire-gateway/transfers")
    225             .assertOkJson<TransferList> {
    226             assertEquals(6, it.transfers.size)
    227             assertEquals(
    228                 it, 
    229                 client.getA("/taler-wire-gateway/transfers?status=pending").assertOkJson<TransferList>()
    230             )
    231         }
    232         client.getA("/taler-wire-gateway/transfers?status=success").assertNoContent()
    233 
    234         db.initiated.batchSubmissionSuccess(1, Instant.now(), "ORDER1")
    235         db.initiated.batchSubmissionFailure(2, Instant.now(), "Failure")
    236         db.initiated.batchSubmissionFailure(3, Instant.now(), "Failure")
    237         client.getA("/taler-wire-gateway/transfers?status=transient_failure").assertOkJson<TransferList> {
    238             assertEquals(2, it.transfers.size)
    239         }
    240         client.getA("/taler-wire-gateway/transfers?status=pending").assertOkJson<TransferList> {
    241             assertEquals(4, it.transfers.size)
    242         }
    243     }
    244     
    245     // GET /taler-wire-gateway/history/incoming
    246     @Test
    247     fun historyIncoming() = serverSetup { db ->
    248         authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming")
    249         historyRoutine<IncomingHistory>(
    250             url = "/taler-wire-gateway/history/incoming",
    251             ids = { it.incoming_transactions.map { it.row_id } },
    252             registered = listOf(
    253                 // Reserve transactions using clean add incoming logic
    254                 { addIncoming("CHF:12") },
    255 
    256                 // Reserve transactions using raw bank transaction logic
    257                 { talerableIn(db) },
    258                 { talerableCompletedIn(db) },
    259                 { talerablePreparedIn(db) },
    260                 { talerablePreparedCompletedIn(db) },
    261 
    262                 // KYC transactions using clean add incoming logic
    263                 { addKyc("CHF:12") },
    264 
    265                 // KYC transactions using raw bank transaction logic
    266                 { talerableKycIn(db) },
    267             ),
    268             ignored = listOf(
    269                 // Ignore malformed incoming transaction
    270                 { registerIn(db) },
    271 
    272                 // Ignore malformed incomplete
    273                 { registerIncompleteIn(db) },
    274 
    275                 // Ignore malformed completed
    276                 { registerCompletedIn(db) },
    277 
    278                 // Ignore incompleted
    279                 { talerableIncompleteIn(db) },
    280 
    281                 // Ignore outgoing transaction
    282                 { talerableOut(db) },
    283 
    284                 // Ignore prepared incomplete
    285                 { talerablePreparedIncompleteIn(db) },
    286             )
    287         )
    288     }
    289 
    290     // GET /taler-wire-gateway/history/outgoing
    291     @Test
    292     fun historyOutgoing() = serverSetup { db ->
    293         authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/outgoing")
    294         historyRoutine<OutgoingHistory>(
    295             url = "/taler-wire-gateway/history/outgoing",
    296             ids = { it.outgoing_transactions.map { it.row_id } },
    297             registered = listOf(
    298                 // Transfer using raw bank transaction logic
    299                 { talerableOut(db) },
    300                 
    301                 // And with metadata
    302                 { talerableOut(db, "CON.ID") }
    303             ),
    304             ignored = listOf(
    305                 // Ignore pending transfers
    306                 { transfer() },
    307 
    308                 // Ignore manual incoming transaction
    309                 { talerableIn(db) },
    310 
    311                 // Ignore malformed incoming transaction
    312                 { registerIn(db) },
    313 
    314                 // Ignore malformed outgoing transaction
    315                 { registerOutgoingPayment(db, genOutPay("ignored")) },
    316             )
    317         )
    318         println(client.getA("/taler-wire-gateway/history/outgoing?limit=2")
    319             .assertOkJson<OutgoingHistory>()
    320             .outgoing_transactions
    321             .map { it.amount.toString() to it.metadata })
    322         assertContentEquals(
    323             client.getA("/taler-wire-gateway/history/outgoing?limit=2")
    324                 .assertOkJson<OutgoingHistory>()
    325                 .outgoing_transactions
    326                 .map { it.amount.toString() to it.metadata }
    327             ,listOf(
    328                 "CHF:44" to null,
    329                 "CHF:44" to "CON.ID",
    330             )
    331         )
    332     }
    333 
    334     suspend fun ApplicationTestBuilder.talerAddIncomingRoutine(type: IncomingType) {
    335         val (path, key) = when (type) {
    336             IncomingType.reserve -> Pair("add-incoming", "reserve_pub")
    337             IncomingType.kyc -> Pair("add-kycauth", "account_pub")
    338             IncomingType.map -> Pair("add-mapped", "authorization_pub")
    339         }
    340      
    341         val (priv, pub) = EddsaPublicKey.randEdsaKeyPair()
    342         client.post("/taler-prepared-transfer/registration") {
    343             json {
    344                 "credit_amount" to "CHF:44"
    345                 "type" to "reserve"
    346                 "alg" to "EdDSA"
    347                 "account_pub" to pub
    348                 "authorization_pub" to pub
    349                 "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv)
    350                 "recurrent" to false
    351             }
    352         }.assertOkJson<SubjectResult>()
    353         val valid_req = obj {
    354             "amount" to "CHF:44"
    355             key to pub
    356             "debit_account" to grothoffPayto
    357         }
    358 
    359         authRoutine(HttpMethod.Post, "/taler-wire-gateway/admin/$path")
    360 
    361         // Check OK
    362         client.postA("/taler-wire-gateway/admin/$path") {
    363             json(valid_req)
    364         }.assertOk()
    365 
    366         when (type) {
    367             IncomingType.reserve -> {
    368                 // Trigger conflict due to reused reserve_pub
    369                 client.postA("/taler-wire-gateway/admin/$path") {
    370                     json(valid_req)
    371                 }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT)
    372             }
    373             IncomingType.kyc -> {
    374                 // Non conflict on reuse
    375                 client.postA("/taler-wire-gateway/admin/$path") {
    376                     json(valid_req)
    377                 }.assertOk()
    378             }
    379             IncomingType.map -> {
    380                 // Trigger conflict due to reused authorization_pub
    381                 client.postA("/taler-wire-gateway/admin/$path") {
    382                     json(valid_req)
    383                 }.assertConflict(TalerErrorCode.BANK_TRANSFER_MAPPING_REUSED)
    384                 // Trigger conflict due to unknown authorization_pub
    385                 client.postA("/taler-wire-gateway/admin/$path") {
    386                     json(valid_req) {
    387                         key to EddsaPublicKey.randEdsaKey()
    388                     }
    389                 }.assertConflict(TalerErrorCode.BANK_TRANSFER_MAPPING_UNKNOWN)
    390             }
    391         }
    392 
    393         // Currency mismatch
    394         client.postA("/taler-wire-gateway/admin/$path") {
    395             json(valid_req) { "amount" to "EUR:33" }
    396         }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
    397 
    398         // Bad BASE32 reserve_pub
    399         client.postA("/taler-wire-gateway/admin/$path") {
    400             json(valid_req) { 
    401                 key to "I love chocolate"
    402             }
    403         }.assertBadRequest()
    404         
    405         // Bad BASE32 len reserve_pub
    406         client.postA("/taler-wire-gateway/admin/$path") {
    407             json(valid_req) { 
    408                 key to Base32Crockford.encode(ByteArray(31).rand())
    409             }
    410         }.assertBadRequest()
    411 
    412         // Bad payto kind
    413         client.postA("/taler-wire-gateway/admin/$path") {
    414             json(valid_req) { 
    415                 "debit_account" to "payto://x-taler-bank/bank.hostname.test/bar"
    416             }
    417         }.assertBadRequest()
    418     }
    419 
    420     // POST /taler-wire-gateway/admin/add-incoming
    421     @Test
    422     fun addIncoming() = serverSetup {
    423         talerAddIncomingRoutine(IncomingType.reserve) 
    424     }
    425 
    426     // POST /taler-wire-gateway/admin/add-kycauth
    427     @Test
    428     fun addKycAuth() = serverSetup {
    429         talerAddIncomingRoutine(IncomingType.kyc) 
    430     }
    431 
    432     // POST /taler-wire-gateway/admin/add-mapped
    433     @Test
    434     fun addMapped() = serverSetup {
    435         talerAddIncomingRoutine(IncomingType.map) 
    436     }
    437 
    438     @Test
    439     fun addIncomingMix() = serverSetup { db ->
    440         addIncoming("CHF:1")
    441         addKyc("CHF:2")
    442         talerableIn(db, amount = "CHF:3")
    443         talerableKycIn(db, amount = "CHF:4")
    444         talerablePreparedIn(db, amount = "CHF:5")
    445         client.getA("/taler-wire-gateway/history/incoming?limit=25").assertOkJson<IncomingHistory> {
    446             assertEquals(5, it.incoming_transactions.size)
    447             it.incoming_transactions.forEachIndexed { i, tx ->
    448                 assertEquals(TalerAmount("CHF:${i+1}"), tx.amount)
    449                 if (i % 2 == 1) {
    450                     val tmp = assertIs<IncomingKycAuthTransaction>(tx)
    451                     if (i < 4) {
    452                         assertNull(tmp.authorization_pub)
    453                         assertNull(tmp.authorization_sig)
    454                     } else {
    455                         assertNotNull(tmp.authorization_pub)
    456                         assertNotNull(tmp.authorization_sig)
    457                     }
    458                 } else {
    459                     val tmp = assertIs<IncomingReserveTransaction>(tx)
    460                     if (i < 4) {
    461                         assertNull(tmp.authorization_pub)
    462                         assertNull(tmp.authorization_sig)
    463                     } else {
    464                         assertNotNull(tmp.authorization_pub)
    465                         assertNotNull(tmp.authorization_sig)
    466                     }
    467                 }
    468                
    469             }
    470         }
    471     }
    472 
    473     // POST /taler-wire-gateway/account/check
    474     @Test
    475     fun accountCheck() = serverSetup {
    476         client.getA("/taler-wire-gateway/account/check").assertNotImplemented()
    477     }
    478 
    479     @Test
    480     fun noApi() = serverSetup("mini.conf") {
    481         client.get("/taler-wire-gateway/config").assertNotImplemented()
    482     }
    483 
    484     @Test
    485     fun auth() = serverSetup("auth.conf") {
    486         authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming", false)
    487         client.get("/taler-wire-gateway/history/incoming") {
    488             basicAuth("username", "password")
    489         }.assertNoContent()
    490     }
    491 }