libeufin

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

WireGatewayApiTest.kt (13520B)


      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.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.nexus.cli.registerOutgoingPayment
     26 import tech.libeufin.ebics.randEbicsId
     27 import java.time.Instant
     28 import kotlin.test.*
     29 
     30 class WireGatewayApiTest {
     31     // GET /taler-wire-gateway/config
     32     @Test
     33     fun config() = serverSetup {
     34         client.get("/taler-wire-gateway/config").assertOk()
     35     }
     36 
     37     // POST /taler-wire-gateway/transfer
     38     @Test
     39     fun transfer() = serverSetup { 
     40         val valid_req = obj {
     41             "request_uid" to HashCode.rand()
     42             "amount" to "CHF:55"
     43             "exchange_base_url" to "http://exchange.example.com/"
     44             "wtid" to ShortHashCode.rand()
     45             "credit_account" to grothoffPayto
     46         }
     47 
     48         authRoutine(HttpMethod.Post, "/taler-wire-gateway/transfer")
     49 
     50         // Check OK
     51         client.postA("/taler-wire-gateway/transfer") {
     52             json(valid_req)
     53         }.assertOk()
     54 
     55         // check idempotency
     56         client.postA("/taler-wire-gateway/transfer") {
     57             json(valid_req)
     58         }.assertOk()
     59 
     60         // Trigger conflict due to reused request_uid
     61         client.postA("/taler-wire-gateway/transfer") {
     62             json(valid_req) { 
     63                 "wtid" to ShortHashCode.rand()
     64                 "exchange_base_url" to "http://different-exchange.example.com/"
     65             }
     66         }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED)
     67 
     68         // Trigger conflict due to reused wtid
     69         client.postA("/taler-wire-gateway/transfer") {
     70             json(valid_req) { 
     71                 "request_uid" to HashCode.rand()
     72             }
     73         }.assertConflict(TalerErrorCode.BANK_TRANSFER_WTID_REUSED)
     74 
     75         // Currency mismatch
     76         client.postA("/taler-wire-gateway/transfer") {
     77             json(valid_req) {
     78                 "amount" to "EUR:33"
     79             }
     80         }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
     81 
     82         // Bad BASE32 wtid
     83         client.postA("/taler-wire-gateway/transfer") {
     84             json(valid_req) { 
     85                 "wtid" to "I love chocolate"
     86             }
     87         }.assertBadRequest()
     88         
     89         // Bad BASE32 len wtid
     90         client.postA("/taler-wire-gateway/transfer") {
     91             json(valid_req) { 
     92                 "wtid" to Base32Crockford.encode(ByteArray(31).rand())
     93             }
     94         }.assertBadRequest()
     95 
     96         // Bad BASE32 request_uid
     97         client.postA("/taler-wire-gateway/transfer") {
     98             json(valid_req) { 
     99                 "request_uid" to "I love chocolate"
    100             }
    101         }.assertBadRequest()
    102 
    103         // Bad BASE32 len wtid
    104         client.postA("/taler-wire-gateway/transfer") {
    105             json(valid_req) { 
    106                 "request_uid" to Base32Crockford.encode(ByteArray(65).rand())
    107             }
    108         }.assertBadRequest()
    109 
    110         // Missing receiver-name
    111         client.postA("/taler-wire-gateway/transfer") {
    112             json(valid_req) { 
    113                 "credit_account" to "payto://iban/CH7389144832588726658"
    114             }
    115         }.assertBadRequest()
    116 
    117         // Bad payto kind
    118         client.postA("/taler-wire-gateway/transfer") {
    119             json(valid_req) { 
    120                 "credit_account" to "payto://x-taler-bank/bank.hostname.test/bar?receiver-name=Mr+Tom"
    121             }
    122         }.assertBadRequest()
    123 
    124         // Bad baseURL
    125         for (bad in sequenceOf("not-a-url", "file://not.http.com/", "no.transport.com/", "https://not.a/base/url")) {
    126             client.postA("/taler-wire-gateway/transfer") {
    127                 json(valid_req) {
    128                     "exchange_base_url" to bad
    129                 }
    130             }.assertBadRequest()
    131         }
    132     }
    133 
    134     // GET /taler-wire-gateway/transfers/{ROW_ID}
    135     @Test
    136     fun transferById() = serverSetup { 
    137         val wtid = ShortHashCode.rand()
    138         val valid_req = obj {
    139             "request_uid" to HashCode.rand()
    140             "amount" to "CHF:55"
    141             "exchange_base_url" to "http://exchange.example.com/"
    142             "wtid" to wtid
    143             "credit_account" to grothoffPayto
    144         }
    145 
    146         authRoutine(HttpMethod.Get, "/taler-wire-gateway/transfers/1")
    147 
    148         val resp = client.postA("/taler-wire-gateway/transfer") {
    149             json(valid_req)
    150         }.assertOkJson<TransferResponse>()
    151 
    152         // Check OK
    153         client.getA("/taler-wire-gateway/transfers/${resp.row_id}")
    154             .assertOkJson<TransferStatus> { tx ->
    155             assertEquals(TransferStatusState.pending, tx.status)
    156             assertEquals(TalerAmount("CHF:55"), tx.amount)
    157             assertEquals("http://exchange.example.com/", tx.origin_exchange_url)
    158             assertEquals(wtid, tx.wtid)
    159             assertEquals(resp.timestamp, tx.timestamp)
    160         }
    161 
    162         // Check unknown transaction
    163         client.getA("/taler-wire-gateway/transfers/42")
    164             .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
    165     }
    166 
    167     // GET /accounts/{USERNAME}/taler-wire-gateway/transfers
    168     @Test
    169     fun transferPage() = serverSetup { db ->
    170         authRoutine(HttpMethod.Get, "/taler-wire-gateway/transfers")
    171 
    172         client.getA("/taler-wire-gateway/transfers").assertNoContent()
    173 
    174         repeat(6) {
    175             client.postA("/taler-wire-gateway/transfer") {
    176                 json {
    177                     "request_uid" to HashCode.rand()
    178                     "amount" to "CHF:55"
    179                     "exchange_base_url" to "http://exchange.example.com/"
    180                     "wtid" to ShortHashCode.rand()
    181                     "credit_account" to grothoffPayto
    182                 }
    183             }.assertOkJson<TransferResponse>()
    184             db.initiated.batch(Instant.now(), randEbicsId(), false)
    185         }
    186         client.getA("/taler-wire-gateway/transfers")
    187             .assertOkJson<TransferList> {
    188             assertEquals(6, it.transfers.size)
    189             assertEquals(
    190                 it, 
    191                 client.getA("/taler-wire-gateway/transfers?status=pending").assertOkJson<TransferList>()
    192             )
    193         }
    194         client.getA("/taler-wire-gateway/transfers?status=success").assertNoContent()
    195 
    196         db.initiated.batchSubmissionSuccess(1, Instant.now(), "ORDER1")
    197         db.initiated.batchSubmissionFailure(2, Instant.now(), "Failure")
    198         db.initiated.batchSubmissionFailure(3, Instant.now(), "Failure")
    199         client.getA("/taler-wire-gateway/transfers?status=transient_failure").assertOkJson<TransferList> {
    200             assertEquals(2, it.transfers.size)
    201         }
    202         client.getA("/taler-wire-gateway/transfers?status=pending").assertOkJson<TransferList> {
    203             assertEquals(4, it.transfers.size)
    204         }
    205     }
    206     
    207     // GET /taler-wire-gateway/history/incoming
    208     @Test
    209     fun historyIncoming() = serverSetup { db ->
    210         authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming")
    211         historyRoutine<IncomingHistory>(
    212             url = "/taler-wire-gateway/history/incoming",
    213             ids = { it.incoming_transactions.map { it.row_id } },
    214             registered = listOf(
    215                 // Reserve transactions using clean add incoming logic
    216                 { addIncoming("CHF:12") },
    217 
    218                 // Reserve transactions using raw bank transaction logic
    219                 { talerableIn(db) },
    220                 { talerableCompletedIn(db) },
    221 
    222                 // KYC transactions using clean add incoming logic
    223                 { addKyc("CHF:12") },
    224 
    225                 // KYC transactions using raw bank transaction logic
    226                 { talerableKycIn(db) },
    227             ),
    228             ignored = listOf(
    229                 // Ignore malformed incoming transaction
    230                 { registerIn(db) },
    231 
    232                 // Ignore malformed incomplete
    233                 { registerIncompleteIn(db) },
    234 
    235                 // Ignore malformed completed
    236                 { registerCompletedIn(db) },
    237 
    238                 // Ignore incompleted
    239                 { talerableIncompleteIn(db) },
    240 
    241                 // Ignore outgoing transaction
    242                 { talerableOut(db) },
    243             )
    244         )
    245     }
    246 
    247     // GET /taler-wire-gateway/history/outgoing
    248     @Test
    249     fun historyOutgoing() = serverSetup { db ->
    250         authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/outgoing")
    251         historyRoutine<OutgoingHistory>(
    252             url = "/taler-wire-gateway/history/outgoing",
    253             ids = { it.outgoing_transactions.map { it.row_id } },
    254             registered = listOf(
    255                 // Transfer using raw bank transaction logic
    256                 { talerableOut(db) },
    257             ),
    258             ignored = listOf(
    259                 // Ignore pending transfers
    260                 { transfer() },
    261 
    262                 // Ignore manual incoming transaction
    263                 { talerableIn(db) },
    264 
    265                 // Ignore malformed incoming transaction
    266                 { registerIn(db) },
    267 
    268                 // Ignore malformed outgoing transaction
    269                 { registerOutgoingPayment(db, genOutPay("ignored")) },
    270             )
    271         )
    272     }
    273 
    274     suspend fun ApplicationTestBuilder.talerAddIncomingRoutine(type: IncomingType) {
    275         val (path, key) = when (type) {
    276             IncomingType.reserve -> Pair("add-incoming", "reserve_pub")
    277             IncomingType.kyc -> Pair("add-kycauth", "account_pub")
    278             IncomingType.wad -> throw UnsupportedOperationException()
    279         }
    280         val valid_req = obj {
    281             "amount" to "CHF:44"
    282             key to EddsaPublicKey.rand()
    283             "debit_account" to grothoffPayto
    284         }
    285 
    286         authRoutine(HttpMethod.Post, "/taler-wire-gateway/admin/$path")
    287 
    288         // Check OK
    289         client.postA("/taler-wire-gateway/admin/$path") {
    290             json(valid_req)
    291         }.assertOk()
    292 
    293         if (type == IncomingType.reserve) {
    294             // Trigger conflict due to reused reserve_pub
    295             client.postA("/taler-wire-gateway/admin/$path") {
    296                 json(valid_req)
    297             }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT)
    298         } else if (type == IncomingType.kyc) {
    299             // Non conflict on reuse
    300             client.postA("/taler-wire-gateway/admin/$path") {
    301                 json(valid_req)
    302             }.assertOk()
    303         }
    304 
    305         // Currency mismatch
    306         client.postA("/taler-wire-gateway/admin/$path") {
    307             json(valid_req) { "amount" to "EUR:33" }
    308         }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
    309 
    310         // Bad BASE32 reserve_pub
    311         client.postA("/taler-wire-gateway/admin/$path") {
    312             json(valid_req) { 
    313                 key to "I love chocolate"
    314             }
    315         }.assertBadRequest()
    316         
    317         // Bad BASE32 len reserve_pub
    318         client.postA("/taler-wire-gateway/admin/$path") {
    319             json(valid_req) { 
    320                 key to Base32Crockford.encode(ByteArray(31).rand())
    321             }
    322         }.assertBadRequest()
    323 
    324         // Bad payto kind
    325         client.postA("/taler-wire-gateway/admin/$path") {
    326             json(valid_req) { 
    327                 "debit_account" to "payto://x-taler-bank/bank.hostname.test/bar"
    328             }
    329         }.assertBadRequest()
    330     }
    331 
    332     // POST /taler-wire-gateway/admin/add-incoming
    333     @Test
    334     fun addIncoming() = serverSetup {
    335         talerAddIncomingRoutine(IncomingType.reserve) 
    336     }
    337 
    338     // POST /taler-wire-gateway/admin/add-kycauth
    339     @Test
    340     fun addKycAuth() = serverSetup {
    341         talerAddIncomingRoutine(IncomingType.kyc) 
    342     }
    343 
    344     @Test
    345     fun addIncomingMix() = serverSetup { db ->
    346         addIncoming("CHF:1")
    347         addKyc("CHF:2")
    348         talerableIn(db, amount = "CHF:3")
    349         talerableKycIn(db, amount = "CHF:4")
    350         client.getA("/taler-wire-gateway/history/incoming?limit=25").assertOkJson<IncomingHistory> {
    351             assertEquals(4, it.incoming_transactions.size)
    352             it.incoming_transactions.forEachIndexed { i, tx ->
    353                 assertEquals(TalerAmount("CHF:${i+1}"), tx.amount)
    354                 if (i % 2 == 1) {
    355                     assertIs<IncomingKycAuthTransaction>(tx)
    356                 } else {
    357                     assertIs<IncomingReserveTransaction>(tx)
    358                 }
    359             }
    360         }
    361     }
    362 
    363     // POST /taler-wire-gateway/account/check
    364     @Test
    365     fun accountCheck() = serverSetup {
    366         client.getA("/taler-wire-gateway/account/check").assertNotImplemented()
    367     }
    368 
    369     @Test
    370     fun noApi() = serverSetup("mini.conf") {
    371         client.get("/taler-wire-gateway/config").assertNotImplemented()
    372     }
    373 
    374     @Test
    375     fun auth() = serverSetup("auth.conf") {
    376         authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming", false)
    377         client.get("/taler-wire-gateway/history/incoming") {
    378             basicAuth("username", "password")
    379         }.assertNoContent()
    380     }
    381 }