libeufin

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

WireGatewayApiTest.kt (20264B)


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