libeufin

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

WireGatewayApiTest.kt (18407B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2023-2025 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.http.*
     21 import io.ktor.server.testing.*
     22 import io.ktor.client.request.*
     23 import org.junit.Test
     24 import tech.libeufin.common.*
     25 import tech.libeufin.common.test.*
     26 import kotlin.test.*
     27 
     28 class WireGatewayApiTest {
     29     // GET /accounts/{USERNAME}/taler-wire-gateway/config
     30     @Test
     31     fun config() = bankSetup {
     32         client.get("/accounts/merchant/taler-wire-gateway/config").assertOk()
     33     }
     34 
     35     // POST /accounts/{USERNAME}/taler-wire-gateway/transfer
     36     @Test
     37     fun transfer() = bankSetup { 
     38         val valid_req = obj {
     39             "request_uid" to HashCode.rand()
     40             "amount" to "KUDOS:55"
     41             "exchange_base_url" to "http://exchange.example.com/"
     42             "wtid" to ShortHashCode.rand()
     43             "credit_account" to merchantPayto.canonical
     44         }
     45 
     46         authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/transfer", valid_req)
     47 
     48         // Checking exchange debt constraint.
     49         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
     50             json(valid_req)
     51         }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
     52 
     53         // Giving debt allowance and checking the OK case.
     54         setMaxDebt("exchange", "KUDOS:1000")
     55         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
     56             json(valid_req)
     57         }.assertOk()
     58 
     59         // check idempotency
     60         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
     61             json(valid_req)
     62         }.assertOk()
     63 
     64         val new_req = obj(valid_req) {
     65             "request_uid" to HashCode.rand()
     66             "wtid" to ShortHashCode.rand()
     67             "credit_account" to adminPayto
     68         }
     69 
     70         // Check conversion bounce
     71         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
     72             json(new_req)
     73         }.assertOk()
     74 
     75         // check idempotency
     76         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
     77             json(new_req)
     78         }.assertOk()
     79       
     80 
     81         // Trigger conflict due to reused request_uid
     82         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
     83             json(valid_req) { 
     84                 "wtid" to ShortHashCode.rand()
     85                 "exchange_base_url" to "http://different-exchange.example.com/"
     86             }
     87         }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED)
     88         
     89         // Trigger conflict due to reused wtid
     90         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
     91             json(valid_req) { 
     92                 "request_uid" to HashCode.rand()
     93             }
     94         }.assertConflict(TalerErrorCode.BANK_TRANSFER_WTID_REUSED)
     95 
     96         // Currency mismatch
     97         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
     98             json(valid_req) {
     99                 "amount" to "EUR:33"
    100             }
    101         }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
    102 
    103         // Same account
    104         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    105             json(valid_req) { 
    106                 "request_uid" to HashCode.rand()
    107                 "wtid" to ShortHashCode.rand()
    108                 "credit_account" to exchangePayto
    109             }
    110         }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE)
    111 
    112         // Bad BASE32 wtid
    113         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    114             json(valid_req) { 
    115                 "wtid" to "I love chocolate"
    116             }
    117         }.assertBadRequest()
    118         
    119         // Bad BASE32 len wtid
    120         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    121             json(valid_req) { 
    122                 "wtid" to  randBase32Crockford(31)
    123             }
    124         }.assertBadRequest()
    125 
    126         // Bad BASE32 request_uid
    127         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    128             json(valid_req) { 
    129                 "request_uid" to "I love chocolate"
    130             }
    131         }.assertBadRequest()
    132 
    133         // Bad BASE32 len wtid
    134         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    135             json(valid_req) { 
    136                 "request_uid" to randBase32Crockford(65)
    137             }
    138         }.assertBadRequest()
    139 
    140         // Bad baseURL
    141         for (bad in sequenceOf("not-a-url", "file://not.http.com/", "no.transport.com/", "https://not.a/base/url")) {
    142             client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    143                 json(valid_req) {
    144                     "exchange_base_url" to bad
    145                 }
    146             }.assertBadRequest()
    147         }
    148     }
    149 
    150     @Test
    151     fun transferNoConversion() = bankSetup("test_no_conversion.conf") {
    152         val valid_req = obj {
    153             "request_uid" to HashCode.rand()
    154             "amount" to "KUDOS:55"
    155             "exchange_base_url" to "http://exchange.example.com/"
    156             "wtid" to ShortHashCode.rand()
    157             "credit_account" to merchantPayto.canonical
    158         }
    159         setMaxDebt("exchange", "KUDOS:1000")
    160 
    161         // Transfer works for common accounts
    162         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    163             json(valid_req)
    164         }.assertOk()
    165 
    166         // But fails to admin accounts
    167         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    168             json(valid_req) {
    169                 "credit_account" to adminPayto
    170                 "request_uid" to HashCode.rand()
    171                 "wtid" to ShortHashCode.rand()
    172             }
    173         }.assertConflict(TalerErrorCode.BANK_ADMIN_CREDITOR)
    174     }
    175 
    176     // GET /accounts/{USERNAME}/taler-wire-gateway/transfers/{ROW_ID}
    177     @Test
    178     fun transferById() = bankSetup { 
    179         var wtid = ShortHashCode.rand()
    180         val valid_req = obj {
    181             "request_uid" to HashCode.rand()
    182             "amount" to "KUDOS:0.12"
    183             "exchange_base_url" to "http://exchange.example.com/"
    184             "wtid" to wtid
    185             "credit_account" to merchantPayto.canonical
    186         }
    187 
    188         authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/transfers/1", requireExchange = true)
    189 
    190         val resp = client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    191             json(valid_req)
    192         }.assertOkJson<TransferResponse>()
    193 
    194         // Check OK
    195         client.getA("/accounts/exchange/taler-wire-gateway/transfers/${resp.row_id}")
    196             .assertOkJson<TransferStatus> { tx ->
    197             assertEquals(TransferStatusState.success, tx.status)
    198             assertEquals(TalerAmount("KUDOS:0.12"), tx.amount)
    199             assertEquals("http://exchange.example.com/", tx.origin_exchange_url)
    200             assertEquals(wtid, tx.wtid)
    201             assertEquals(resp.timestamp, tx.timestamp)
    202         }
    203         // Unknown account
    204         wtid = ShortHashCode.rand()
    205         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    206             json(valid_req) { 
    207                 "request_uid" to HashCode.rand()
    208                 "wtid" to wtid
    209                 "credit_account" to unknownPayto
    210             }
    211         }.assertOkJson<TransferResponse> { resp ->
    212             client.getA("/accounts/exchange/taler-wire-gateway/transfers/${resp.row_id}")
    213                 .assertOkJson<TransferStatus> { tx ->
    214                 assertEquals(TransferStatusState.permanent_failure, tx.status)
    215                 assertEquals(TalerAmount("KUDOS:0.12"), tx.amount)
    216                 assertEquals("http://exchange.example.com/", tx.origin_exchange_url)
    217                 assertEquals(wtid, tx.wtid)
    218                 assertEquals(resp.timestamp, tx.timestamp)
    219             }
    220         }
    221         // Check unknown transaction
    222         client.getA("/accounts/exchange/taler-wire-gateway/transfers/42")
    223             .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
    224         // Check another user's transaction
    225         client.getA("/accounts/merchant/taler-wire-gateway/transfers/${resp.row_id}")
    226             .assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE)
    227     }
    228 
    229     // GET /accounts/{USERNAME}/taler-wire-gateway/transfers
    230     @Test
    231     fun transferPage() = bankSetup {
    232         authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/transfers", requireExchange = true)
    233 
    234         client.getA("/accounts/exchange/taler-wire-gateway/transfers").assertNoContent()
    235 
    236         repeat(5) {
    237             client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    238                 json {
    239                     "request_uid" to HashCode.rand()
    240                     "amount" to "KUDOS:0.12"
    241                     "exchange_base_url" to "http://exchange.example.com/"
    242                     "wtid" to ShortHashCode.rand()
    243                     "credit_account" to merchantPayto
    244                 }
    245             }.assertOkJson<TransferResponse>()
    246         }
    247         client.getA("/accounts/exchange/taler-wire-gateway/transfers")
    248             .assertOkJson<TransferList> {
    249             assertEquals(5, it.transfers.size)
    250             assertEquals(
    251                 it, 
    252                 client.getA("/accounts/exchange/taler-wire-gateway/transfers?status=success").assertOkJson<TransferList>()
    253             )
    254         }
    255         client.getA("/accounts/exchange/taler-wire-gateway/transfers?status=pending").assertNoContent()
    256         client.getA("/accounts/exchange/taler-wire-gateway/transfers?status=permanent_failure").assertNoContent()
    257 
    258         client.postA("/accounts/exchange/taler-wire-gateway/transfer") {
    259             json {
    260                 "request_uid" to HashCode.rand()
    261                 "amount" to "KUDOS:0.12"
    262                 "exchange_base_url" to "http://exchange.example.com/"
    263                 "wtid" to ShortHashCode.rand()
    264                 "credit_account" to unknownPayto
    265             }
    266         }.assertOkJson<TransferResponse>()
    267         client.getA("/accounts/exchange/taler-wire-gateway/transfers").assertOkJson<TransferList> {
    268             assertEquals(6, it.transfers.size)
    269         }
    270         client.getA("/accounts/exchange/taler-wire-gateway/transfers?status=success").assertOkJson<TransferList> {
    271             assertEquals(5, it.transfers.size)
    272         }
    273         client.getA("/accounts/exchange/taler-wire-gateway/transfers?status=permanent_failure").assertOkJson<TransferList> {
    274             assertEquals(1, it.transfers.size)
    275         }
    276     }
    277     
    278     // GET /accounts/{USERNAME}/taler-wire-gateway/history/incoming
    279     @Test
    280     fun historyIncoming() = bankSetup { 
    281         // Give Foo reasonable debt allowance:
    282         setMaxDebt("merchant", "KUDOS:1000")
    283         authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/history/incoming", requireExchange = true)
    284         historyRoutine<IncomingHistory>(
    285             url = "/accounts/exchange/taler-wire-gateway/history/incoming",
    286             ids = { it.incoming_transactions.map { it.row_id } },
    287             registered = listOf(
    288                 // Reserve transactions using clean add incoming logic
    289                 { addIncoming("KUDOS:10") },
    290 
    291                 // Reserve transactions using raw bank transaction logic
    292                 { tx("merchant", "KUDOS:10", "exchange", "history test with ${EddsaPublicKey.randEdsaKey()} reserve pub") },
    293 
    294                 // Reserve transactions using withdraw logic
    295                 { withdrawal("KUDOS:9") },
    296 
    297                 // KYC transaction using clean add incoming logic 
    298                 { addKyc("KUDOS:2") },
    299 
    300                 // KYC transactions using raw bank transaction logic
    301                 { tx("merchant", "KUDOS:2", "exchange", "history test with KYC:${EddsaPublicKey.randEdsaKey()} account pub") },
    302             ),
    303             ignored = listOf(
    304                 // Ignore malformed incoming transaction
    305                 { tx("merchant", "KUDOS:10", "exchange", "ignored") },
    306 
    307                 // Ignore malformed outgoing transaction
    308                 { tx("exchange", "KUDOS:10", "merchant", "ignored") },
    309             )
    310         )
    311     }
    312 
    313     // GET /accounts/{USERNAME}/taler-wire-gateway/history/outgoing
    314     @Test
    315     fun historyOutgoing() = bankSetup {
    316         setMaxDebt("exchange", "KUDOS:1000000")
    317         authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/history/outgoing", requireExchange = true)
    318         historyRoutine<OutgoingHistory>(
    319             url = "/accounts/exchange/taler-wire-gateway/history/outgoing",
    320             ids = { it.outgoing_transactions.map { it.row_id } },
    321             registered = listOf(
    322                 // Transactions using clean add incoming logic
    323                 { transfer("KUDOS:10") }
    324             ),
    325             ignored = listOf(
    326                 // Failed transfer
    327                 { transfer("KUDOS:10", unknownPayto) },
    328                 
    329                 // Ignore manual outgoing transaction
    330                 { tx("exchange", "KUDOS:10", "merchant", "${ShortHashCode.rand()} http://exchange.example.com/") },
    331 
    332                 // Ignore malformed incoming transaction
    333                 { tx("merchant", "KUDOS:10", "exchange", "ignored") },
    334 
    335                 // Ignore malformed outgoing transaction
    336                 { tx("exchange", "KUDOS:10", "merchant", "ignored") },
    337             )
    338         )
    339     }
    340 
    341     suspend fun ApplicationTestBuilder.talerAddIncomingRoutine(type: IncomingType) {
    342         val (path, key) = when (type) {
    343             IncomingType.reserve -> Pair("add-incoming", "reserve_pub")
    344             IncomingType.kyc -> Pair("add-kycauth", "account_pub")
    345             IncomingType.wad -> throw UnsupportedOperationException()
    346         }
    347         val valid_req = obj {
    348             "amount" to "KUDOS:44"
    349             key to EddsaPublicKey.rand()
    350             "debit_account" to merchantPayto.canonical
    351         }
    352 
    353         authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/admin/$path", valid_req, requireAdmin = true)
    354 
    355         // Checking exchange debt constraint.
    356         client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") {
    357             json(valid_req)
    358         }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
    359 
    360         // Giving debt allowance and checking the OK case.
    361         setMaxDebt("merchant", "KUDOS:1000")
    362         client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") {
    363             json(valid_req)
    364         }.assertOk()
    365 
    366         if (type == IncomingType.reserve) {
    367             // Trigger conflict due to reused reserve_pub
    368             client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") {
    369                 json(valid_req)
    370             }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT)
    371         } else if (type == IncomingType.kyc) {
    372             // Non conflict on reuse
    373             client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") {
    374                 json(valid_req)
    375             }.assertOk()
    376         }
    377 
    378         // Currency mismatch
    379         client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") {
    380             json(valid_req) { "amount" to "EUR:33" }
    381         }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
    382 
    383         // Unknown account
    384         client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") {
    385             json(valid_req) { 
    386                 key to EddsaPublicKey.rand()
    387                 "debit_account" to unknownPayto
    388             }
    389         }.assertConflict(TalerErrorCode.BANK_UNKNOWN_DEBTOR)
    390 
    391         // Same account
    392         client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") {
    393             json(valid_req) { 
    394                 key to EddsaPublicKey.rand()
    395                 "debit_account" to exchangePayto
    396             }
    397         }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE)
    398 
    399         // Bad BASE32 reserve_pub
    400         client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") {
    401             json(valid_req) { 
    402                 key to "I love chocolate"
    403             }
    404         }.assertBadRequest()
    405         
    406         // Bad BASE32 len reserve_pub
    407         client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") {
    408             json(valid_req) { 
    409                 key to randBase32Crockford(31)
    410             }
    411         }.assertBadRequest()
    412     }
    413 
    414     // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming
    415     @Test
    416     fun addIncoming() = bankSetup {
    417         talerAddIncomingRoutine(IncomingType.reserve)
    418     }
    419 
    420     // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-kycauth
    421     @Test
    422     fun addKycAuth() = bankSetup {
    423         talerAddIncomingRoutine(IncomingType.kyc)
    424     }
    425 
    426     @Test
    427     fun addIncomingMix() = bankSetup {
    428         addIncoming("KUDOS:1")
    429         addKyc("KUDOS:2")
    430         tx("merchant", "KUDOS:3", "exchange", "test with ${EddsaPublicKey.randEdsaKey()} reserve pub")
    431         tx("merchant", "KUDOS:4", "exchange", "test with KYC:${EddsaPublicKey.randEdsaKey()} account pub")
    432         client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?limit=25").assertOkJson<IncomingHistory> {
    433             assertEquals(4, it.incoming_transactions.size)
    434             it.incoming_transactions.forEachIndexed { i, tx ->
    435                 assertEquals(TalerAmount("KUDOS:${i+1}"), tx.amount)
    436                 if (i % 2 == 1) {
    437                     assertIs<IncomingKycAuthTransaction>(tx)
    438                 } else {
    439                     assertIs<IncomingReserveTransaction>(tx)
    440                 }
    441             }
    442         }
    443     }
    444 
    445     // POST /taler-wire-gateway/account/check
    446     @Test
    447     fun accountCheck() = bankSetup {
    448         client.getA("/accounts/exchange/taler-wire-gateway/account/check").assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MISSING)
    449         client.getA("/accounts/exchange/taler-wire-gateway/account/check?account=$unknownPayto").assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
    450         client.getA("/accounts/exchange/taler-wire-gateway/account/check?account=$merchantPayto").assertOkJson<AccountInfo>()
    451     }
    452 }