libeufin

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

WireGatewayApiTest.kt (21712B)


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