libeufin

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

WireGatewayApiTest.kt (16475B)


      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.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         val with_metadata = obj(valid_req) {
     61             "request_uid" to HashCode.rand()
     62             "metadata" to "ID"
     63             "wtid" to ShortHashCode.rand()
     64         }
     65         client.postA("/taler-wire-gateway/transfer") {
     66             json(with_metadata)
     67         }.assertOk()
     68         client.postA("/taler-wire-gateway/transfer") {
     69             json(with_metadata)
     70         }.assertOk()
     71         
     72         // Malformed metadata
     73         listOf("bad_id", "bad id", "bad@id.com", "A".repeat(41)).forEach {
     74             client.postA("/taler-wire-gateway/transfer") {
     75                 json(valid_req) {
     76                     "request_uid" to HashCode.rand()
     77                     "metadata" to it
     78                     "wtid" to ShortHashCode.rand()
     79                 }
     80             }.assertBadRequest()
     81         }
     82 
     83         // Trigger conflict due to reused request_uid
     84         client.postA("/taler-wire-gateway/transfer") {
     85             json(valid_req) { 
     86                 "wtid" to ShortHashCode.rand()
     87                 "exchange_base_url" to "http://different-exchange.example.com/"
     88             }
     89         }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED)
     90 
     91         // Trigger conflict due to reused wtid
     92         client.postA("/taler-wire-gateway/transfer") {
     93             json(valid_req) { 
     94                 "request_uid" to HashCode.rand()
     95             }
     96         }.assertConflict(TalerErrorCode.BANK_TRANSFER_WTID_REUSED)
     97 
     98         // Currency mismatch
     99         client.postA("/taler-wire-gateway/transfer") {
    100             json(valid_req) {
    101                 "amount" to "EUR:33"
    102             }
    103         }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
    104 
    105         // Bad BASE32 wtid
    106         client.postA("/taler-wire-gateway/transfer") {
    107             json(valid_req) { 
    108                 "wtid" to "I love chocolate"
    109             }
    110         }.assertBadRequest()
    111         
    112         // Bad BASE32 len wtid
    113         client.postA("/taler-wire-gateway/transfer") {
    114             json(valid_req) { 
    115                 "wtid" to Base32Crockford.encode(ByteArray(31).rand())
    116             }
    117         }.assertBadRequest()
    118 
    119         // Bad BASE32 request_uid
    120         client.postA("/taler-wire-gateway/transfer") {
    121             json(valid_req) { 
    122                 "request_uid" to "I love chocolate"
    123             }
    124         }.assertBadRequest()
    125 
    126         // Bad BASE32 len wtid
    127         client.postA("/taler-wire-gateway/transfer") {
    128             json(valid_req) { 
    129                 "request_uid" to Base32Crockford.encode(ByteArray(65).rand())
    130             }
    131         }.assertBadRequest()
    132 
    133         // Missing receiver-name
    134         client.postA("/taler-wire-gateway/transfer") {
    135             json(valid_req) { 
    136                 "credit_account" to "payto://iban/CH7389144832588726658"
    137             }
    138         }.assertBadRequest()
    139 
    140         // Bad payto kind
    141         client.postA("/taler-wire-gateway/transfer") {
    142             json(valid_req) { 
    143                 "credit_account" to "payto://x-taler-bank/bank.hostname.test/bar?receiver-name=Mr+Tom"
    144             }
    145         }.assertBadRequest()
    146 
    147         // Bad baseURL
    148         for (bad in sequenceOf("not-a-url", "file://not.http.com/", "no.transport.com/", "https://not.a/base/url")) {
    149             client.postA("/taler-wire-gateway/transfer") {
    150                 json(valid_req) {
    151                     "exchange_base_url" to bad
    152                 }
    153             }.assertBadRequest()
    154         }
    155     }
    156 
    157     // GET /taler-wire-gateway/transfers/{ROW_ID}
    158     @Test
    159     fun transferById() = serverSetup { 
    160         val wtid = ShortHashCode.rand()
    161         val valid_req = obj {
    162             "request_uid" to HashCode.rand()
    163             "amount" to "CHF:55"
    164             "exchange_base_url" to "http://exchange.example.com/"
    165             "wtid" to wtid
    166             "credit_account" to grothoffPayto
    167         }
    168 
    169         authRoutine(HttpMethod.Get, "/taler-wire-gateway/transfers/1")
    170 
    171         val resp = client.postA("/taler-wire-gateway/transfer") {
    172             json(valid_req)
    173         }.assertOkJson<TransferResponse>()
    174 
    175         // Check OK
    176         client.getA("/taler-wire-gateway/transfers/${resp.row_id}")
    177             .assertOkJson<TransferStatus> { tx ->
    178             assertEquals(TransferStatusState.pending, tx.status)
    179             assertEquals(TalerAmount("CHF:55"), tx.amount)
    180             assertEquals("http://exchange.example.com/", tx.origin_exchange_url)
    181             assertNull(tx.metadata)
    182             assertEquals(wtid, tx.wtid)
    183             assertEquals(resp.timestamp, tx.timestamp)
    184         }
    185         
    186         client.postA("/taler-wire-gateway/transfer") {
    187             json(valid_req) {
    188                 "request_uid" to HashCode.rand()
    189                 "metadata" to "ID"
    190                 "wtid" to ShortHashCode.rand()
    191             }
    192         }.assertOkJson<TransferResponse> {
    193             client.getA("/taler-wire-gateway/transfers/${it.row_id}")
    194                 .assertOkJson<TransferStatus> { tx ->
    195                 assertEquals(tx.metadata, "ID")
    196             }
    197         }
    198 
    199         // Check unknown transaction
    200         client.getA("/taler-wire-gateway/transfers/42")
    201             .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
    202     }
    203 
    204     // GET /accounts/{USERNAME}/taler-wire-gateway/transfers
    205     @Test
    206     fun transferPage() = serverSetup { db ->
    207         authRoutine(HttpMethod.Get, "/taler-wire-gateway/transfers")
    208 
    209         client.getA("/taler-wire-gateway/transfers").assertNoContent()
    210 
    211         repeat(6) {
    212             client.postA("/taler-wire-gateway/transfer") {
    213                 json {
    214                     "request_uid" to HashCode.rand()
    215                     "amount" to "CHF:55"
    216                     "exchange_base_url" to "http://exchange.example.com/"
    217                     "wtid" to ShortHashCode.rand()
    218                     "credit_account" to grothoffPayto
    219                 }
    220             }.assertOkJson<TransferResponse>()
    221             db.initiated.batch(Instant.now(), randEbicsId(), false)
    222         }
    223         client.getA("/taler-wire-gateway/transfers")
    224             .assertOkJson<TransferList> {
    225             assertEquals(6, it.transfers.size)
    226             assertEquals(
    227                 it, 
    228                 client.getA("/taler-wire-gateway/transfers?status=pending").assertOkJson<TransferList>()
    229             )
    230         }
    231         client.getA("/taler-wire-gateway/transfers?status=success").assertNoContent()
    232 
    233         db.initiated.batchSubmissionSuccess(1, Instant.now(), "ORDER1")
    234         db.initiated.batchSubmissionFailure(2, Instant.now(), "Failure")
    235         db.initiated.batchSubmissionFailure(3, Instant.now(), "Failure")
    236         client.getA("/taler-wire-gateway/transfers?status=transient_failure").assertOkJson<TransferList> {
    237             assertEquals(2, it.transfers.size)
    238         }
    239         client.getA("/taler-wire-gateway/transfers?status=pending").assertOkJson<TransferList> {
    240             assertEquals(4, it.transfers.size)
    241         }
    242     }
    243     
    244     // GET /taler-wire-gateway/history/incoming
    245     @Test
    246     fun historyIncoming() = serverSetup { db ->
    247         authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming")
    248         historyRoutine<IncomingHistory>(
    249             url = "/taler-wire-gateway/history/incoming",
    250             ids = { it.incoming_transactions.map { it.row_id } },
    251             registered = listOf(
    252                 // Reserve transactions using clean add incoming logic
    253                 { addIncoming("CHF:12") },
    254 
    255                 // Reserve transactions using raw bank transaction logic
    256                 { talerableIn(db) },
    257                 { talerableCompletedIn(db) },
    258                 { talerablePreparedIn(db) },
    259                 { talerablePreparedCompletedIn(db) },
    260 
    261                 // KYC transactions using clean add incoming logic
    262                 { addKyc("CHF:12") },
    263 
    264                 // KYC transactions using raw bank transaction logic
    265                 { talerableKycIn(db) },
    266             ),
    267             ignored = listOf(
    268                 // Ignore malformed incoming transaction
    269                 { registerIn(db) },
    270 
    271                 // Ignore malformed incomplete
    272                 { registerIncompleteIn(db) },
    273 
    274                 // Ignore malformed completed
    275                 { registerCompletedIn(db) },
    276 
    277                 // Ignore incompleted
    278                 { talerableIncompleteIn(db) },
    279 
    280                 // Ignore outgoing transaction
    281                 { talerableOut(db) },
    282 
    283                 // Ignore prepared incomplete
    284                 { talerablePreparedIncompleteIn(db) },
    285             )
    286         )
    287     }
    288 
    289     // GET /taler-wire-gateway/history/outgoing
    290     @Test
    291     fun historyOutgoing() = serverSetup { db ->
    292         authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/outgoing")
    293         historyRoutine<OutgoingHistory>(
    294             url = "/taler-wire-gateway/history/outgoing",
    295             ids = { it.outgoing_transactions.map { it.row_id } },
    296             registered = listOf(
    297                 // Transfer using raw bank transaction logic
    298                 { talerableOut(db) },
    299                 
    300                 // And with metadata
    301                 { talerableOut(db, "CON.ID") }
    302             ),
    303             ignored = listOf(
    304                 // Ignore pending transfers
    305                 { transfer() },
    306 
    307                 // Ignore manual incoming transaction
    308                 { talerableIn(db) },
    309 
    310                 // Ignore malformed incoming transaction
    311                 { registerIn(db) },
    312 
    313                 // Ignore malformed outgoing transaction
    314                 { registerOutgoingPayment(db, genOutPay("ignored")) },
    315             )
    316         )
    317         println(client.getA("/taler-wire-gateway/history/outgoing?limit=2")
    318             .assertOkJson<OutgoingHistory>()
    319             .outgoing_transactions
    320             .map { it.amount.toString() to it.metadata })
    321         assertContentEquals(
    322             client.getA("/taler-wire-gateway/history/outgoing?limit=2")
    323                 .assertOkJson<OutgoingHistory>()
    324                 .outgoing_transactions
    325                 .map { it.amount.toString() to it.metadata }
    326             ,listOf(
    327                 "CHF:44" to null,
    328                 "CHF:44" to "CON.ID",
    329             )
    330         )
    331     }
    332 
    333     suspend fun ApplicationTestBuilder.talerAddIncomingRoutine(type: IncomingType) {
    334         val (path, key) = when (type) {
    335             IncomingType.reserve -> Pair("add-incoming", "reserve_pub")
    336             IncomingType.kyc -> Pair("add-kycauth", "account_pub")
    337             IncomingType.map -> throw UnsupportedOperationException()
    338         }
    339         val valid_req = obj {
    340             "amount" to "CHF:44"
    341             key to EddsaPublicKey.randEdsaKey()
    342             "debit_account" to grothoffPayto
    343         }
    344 
    345         authRoutine(HttpMethod.Post, "/taler-wire-gateway/admin/$path")
    346 
    347         // Check OK
    348         client.postA("/taler-wire-gateway/admin/$path") {
    349             json(valid_req)
    350         }.assertOk()
    351 
    352         if (type == IncomingType.reserve) {
    353             // Trigger conflict due to reused reserve_pub
    354             client.postA("/taler-wire-gateway/admin/$path") {
    355                 json(valid_req)
    356             }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT)
    357         } else if (type == IncomingType.kyc) {
    358             // Non conflict on reuse
    359             client.postA("/taler-wire-gateway/admin/$path") {
    360                 json(valid_req)
    361             }.assertOk()
    362         }
    363 
    364         // Currency mismatch
    365         client.postA("/taler-wire-gateway/admin/$path") {
    366             json(valid_req) { "amount" to "EUR:33" }
    367         }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH)
    368 
    369         // Bad BASE32 reserve_pub
    370         client.postA("/taler-wire-gateway/admin/$path") {
    371             json(valid_req) { 
    372                 key to "I love chocolate"
    373             }
    374         }.assertBadRequest()
    375         
    376         // Bad BASE32 len reserve_pub
    377         client.postA("/taler-wire-gateway/admin/$path") {
    378             json(valid_req) { 
    379                 key to Base32Crockford.encode(ByteArray(31).rand())
    380             }
    381         }.assertBadRequest()
    382 
    383         // Bad payto kind
    384         client.postA("/taler-wire-gateway/admin/$path") {
    385             json(valid_req) { 
    386                 "debit_account" to "payto://x-taler-bank/bank.hostname.test/bar"
    387             }
    388         }.assertBadRequest()
    389     }
    390 
    391     // POST /taler-wire-gateway/admin/add-incoming
    392     @Test
    393     fun addIncoming() = serverSetup {
    394         talerAddIncomingRoutine(IncomingType.reserve) 
    395     }
    396 
    397     // POST /taler-wire-gateway/admin/add-kycauth
    398     @Test
    399     fun addKycAuth() = serverSetup {
    400         talerAddIncomingRoutine(IncomingType.kyc) 
    401     }
    402 
    403     @Test
    404     fun addIncomingMix() = serverSetup { db ->
    405         addIncoming("CHF:1")
    406         addKyc("CHF:2")
    407         talerableIn(db, amount = "CHF:3")
    408         talerableKycIn(db, amount = "CHF:4")
    409         talerablePreparedIn(db, amount = "CHF:5")
    410         client.getA("/taler-wire-gateway/history/incoming?limit=25").assertOkJson<IncomingHistory> {
    411             assertEquals(5, it.incoming_transactions.size)
    412             it.incoming_transactions.forEachIndexed { i, tx ->
    413                 assertEquals(TalerAmount("CHF:${i+1}"), tx.amount)
    414                 if (i % 2 == 1) {
    415                     val tmp = assertIs<IncomingKycAuthTransaction>(tx)
    416                     if (i < 4) {
    417                         assertNull(tmp.authorization_pub)
    418                         assertNull(tmp.authorization_sig)
    419                     } else {
    420                         assertNotNull(tmp.authorization_pub)
    421                         assertNotNull(tmp.authorization_sig)
    422                     }
    423                 } else {
    424                     val tmp = assertIs<IncomingReserveTransaction>(tx)
    425                     if (i < 4) {
    426                         assertNull(tmp.authorization_pub)
    427                         assertNull(tmp.authorization_sig)
    428                     } else {
    429                         assertNotNull(tmp.authorization_pub)
    430                         assertNotNull(tmp.authorization_sig)
    431                     }
    432                 }
    433                
    434             }
    435         }
    436     }
    437 
    438     // POST /taler-wire-gateway/account/check
    439     @Test
    440     fun accountCheck() = serverSetup {
    441         client.getA("/taler-wire-gateway/account/check").assertNotImplemented()
    442     }
    443 
    444     @Test
    445     fun noApi() = serverSetup("mini.conf") {
    446         client.get("/taler-wire-gateway/config").assertNotImplemented()
    447     }
    448 
    449     @Test
    450     fun auth() = serverSetup("auth.conf") {
    451         authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming", false)
    452         client.get("/taler-wire-gateway/history/incoming") {
    453             basicAuth("username", "password")
    454         }.assertNoContent()
    455     }
    456 }