libeufin

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

PreparedTransferApiTest.kt (12205B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 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 org.junit.Test
     22 import tech.libeufin.common.*
     23 import tech.libeufin.common.crypto.CryptoUtil
     24 import tech.libeufin.common.test.*
     25 import java.time.Instant
     26 import kotlin.test.*
     27 
     28 class PreparedTransferApiTest {
     29     // GET /taler-prepared-transfer/config
     30     @Test
     31     fun config() = bankSetup {
     32         client.get("/taler-prepared-transfer/config").assertOkJson<PreparedTransferConfig>()
     33     }
     34 
     35     // POST /taler-prepared-transfer/registration
     36     @Test
     37     fun registration() = bankSetup {
     38         val (priv, pub) = EddsaPublicKey.randEdsaKeyPair()
     39         val amount = TalerAmount("KUDOS:1")
     40         val valid_req = SubjectRequest(
     41             exchangePayto,
     42             TransferType.reserve,
     43             false,
     44             amount,
     45             PublicKeyAlg.EdDSA,
     46             pub,
     47             pub,
     48             EddsaSignature.rand()
     49         )
     50 
     51         val simpleSubject = TransferSubject.Simple("Taler MAP:$pub", amount)
     52 
     53         // Valid
     54         val subjects = client.post("/taler-prepared-transfer/registration") {
     55             json(valid_req.sign(priv))
     56         }.assertOkJson<SubjectResult> {
     57             assertEquals(it.subjects[1], simpleSubject)
     58             assertIs<TransferSubject.Uri>(it.subjects[0])
     59         }.subjects
     60 
     61         // Idempotent
     62         client.post("/taler-prepared-transfer/registration") {
     63             json(valid_req.sign(priv))
     64         }.assertOkJson<SubjectResult> {
     65             assertEquals(it.subjects, subjects)
     66         }
     67 
     68         // KYC has a different withdrawal uri
     69         client.post("/taler-prepared-transfer/registration") {
     70             json(valid_req.copy(type = TransferType.kyc).sign(priv))
     71         }.assertOkJson<SubjectResult> {
     72             assertEquals(it.subjects[1], simpleSubject)
     73             val uriSubject = assertIs<TransferSubject.Uri>(it.subjects[0])
     74             assertNotEquals(subjects[0], uriSubject)
     75         }
     76 
     77         // Recurrent only has simple subject
     78         client.post("/taler-prepared-transfer/registration") {
     79             json(valid_req.copy(recurrent = true).sign(priv))
     80         }.assertOkJson<SubjectResult> {
     81             assertEquals(it.subjects, listOf(simpleSubject))
     82         }
     83 
     84         // Bad signature
     85         client.post("/taler-prepared-transfer/registration") {
     86             json(valid_req)
     87         }.assertForbidden(TalerErrorCode.BANK_BAD_SIGNATURE)
     88 
     89         // Not exchange
     90         client.post("/taler-prepared-transfer/registration") {
     91             json(valid_req.copy(credit_account = merchantPayto).sign(priv))
     92         }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE)
     93 
     94         // Unknown account
     95         client.post("/taler-prepared-transfer/registration") {
     96             json(valid_req.copy(credit_account = unknownPayto).sign(priv))
     97         }.assertConflict(TalerErrorCode.BANK_UNKNOWN_CREDITOR)
     98 
     99         assertBalance("customer", "+KUDOS:0")
    100         assertBalance("exchange", "+KUDOS:0")
    101 
    102         // Non recurrent accept on then bounce
    103         client.post("/taler-prepared-transfer/registration") {
    104             json(valid_req.sign(priv))
    105         }.assertOkJson<SubjectResult> {
    106             val uuid = (it.subjects[0] as? TransferSubject.Uri)!!.uri.substringAfterLast('/')
    107             client.postA("/accounts/customer/withdrawals/$uuid/confirm").assertNoContent() // reserve
    108             client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
    109                 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
    110             tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce
    111             tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce
    112             assertBalance("customer", "-KUDOS:1")
    113             assertBalance("exchange", "+KUDOS:1")
    114         }
    115 
    116         // Withdrawal is aborted on completion
    117         client.post("/taler-prepared-transfer/registration") {
    118             json(valid_req.copy(type = TransferType.kyc).sign(priv))
    119         }.assertOkJson<SubjectResult> {
    120             val uuid = (it.subjects[0] as? TransferSubject.Uri)!!.uri.substringAfterLast('/')
    121             println("UUID $uuid")
    122             tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // kyc
    123             tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce
    124             client.postA("/accounts/customer/withdrawals/$uuid/confirm")
    125                 .assertConflict(TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT) // aborted
    126             assertBalance("customer", "-KUDOS:2")
    127             assertBalance("exchange", "+KUDOS:2")
    128         }
    129 
    130         // Recurrent accept one and delay others
    131         val newKey = EddsaPublicKey.randEdsaKey()
    132         client.post("/taler-prepared-transfer/registration") {
    133             json(valid_req.copy(account_pub = newKey, recurrent = true).sign(priv))
    134         }
    135         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // reserve
    136         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending
    137         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending
    138         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending
    139         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending
    140         assertBalance("customer", "-KUDOS:7")
    141         assertBalance("exchange", "+KUDOS:7")
    142 
    143         // Complete pending on recurrent update
    144         val kycKey = EddsaPublicKey.randEdsaKey()
    145         client.post("/taler-prepared-transfer/registration") {
    146             json(valid_req.copy(type= TransferType.kyc, account_pub = kycKey, recurrent = true).sign(priv))
    147         }.assertOkJson<SubjectResult>()
    148         client.post("/taler-prepared-transfer/registration") {
    149             json(valid_req.copy(account_pub = kycKey, recurrent = true).sign(priv))
    150         }.assertOkJson<SubjectResult>()
    151         assertBalance("customer", "-KUDOS:7")
    152         assertBalance("exchange", "+KUDOS:7")
    153 
    154         // Kyc key reuse keep pending ones
    155         tx("customer", "KUDOS:1", "exchange", "Taler KYC:$kycKey")
    156         assertBalance("customer", "-KUDOS:8")
    157         assertBalance("exchange", "+KUDOS:8")
    158 
    159         // Switching to non recurrent cancel pending
    160         client.post("/taler-prepared-transfer/registration") {
    161             json(valid_req.copy(type= TransferType.kyc, account_pub = kycKey).sign(priv))
    162         }.assertOkJson<SubjectResult>()
    163         assertBalance("customer", "-KUDOS:6")
    164         assertBalance("exchange", "+KUDOS:6")
    165 
    166         // Check authorization field in incoming history
    167         val (testPriv, testAuth) = EddsaPublicKey.randEdsaKeyPair()
    168         val testKey = EddsaPublicKey.randEdsaKey()
    169         val qr = subjectFmtQrBill(testAuth)
    170         client.post("/taler-prepared-transfer/registration") {
    171             json(valid_req.copy(account_pub=testKey, authorization_pub=testAuth, recurrent=true).sign(testPriv))
    172         }.assertOkJson<SubjectResult>()
    173         tx("customer", "KUDOS:0.1", "exchange", "Taler MAP:$testAuth")
    174         tx("customer", "KUDOS:0.1", "exchange", "Taler MAP:$testAuth")
    175         tx("customer", "KUDOS:0.1", "exchange", "Taler MAP:$testAuth")
    176         client.post("/taler-prepared-transfer/registration") {
    177             json(valid_req.copy(type=TransferType.kyc, account_pub=testKey, authorization_pub=testAuth, recurrent=true).sign(testPriv))
    178         }.assertOkJson<SubjectResult>()
    179         val otherPub = EddsaPublicKey.randEdsaKey()
    180         client.post("/taler-prepared-transfer/registration") {
    181             json(valid_req.copy(account_pub=otherPub, authorization_pub=testAuth, recurrent=true).sign(testPriv))
    182         }.assertOkJson<SubjectResult>()
    183         val lastPub = EddsaPublicKey.randEdsaKey()
    184         tx("customer", "KUDOS:0.1", "exchange", "Taler $lastPub")
    185         tx("customer", "KUDOS:0.1", "exchange", "Taler KYC:$lastPub")
    186         val history = client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?limit=-5")
    187             .assertOkJson<IncomingHistory>().incoming_transactions.map {
    188                 when (it) {
    189                     is IncomingKycAuthTransaction -> Pair(it.account_pub, it.authorization_pub)
    190                     is IncomingReserveTransaction -> Pair(it.reserve_pub, it.authorization_pub)
    191                     else -> throw UnsupportedOperationException()
    192                 }
    193             }
    194         assertContentEquals(history, listOf(
    195             Pair(lastPub, null),
    196             Pair(lastPub, null),
    197             Pair(otherPub, testAuth),
    198             Pair(testKey, testAuth),
    199             Pair(testKey, testAuth)
    200         ))
    201     }
    202 
    203     // DELETE /taler-prepared-transfer/registration
    204     @Test
    205     fun unregistration() = bankSetup {
    206         val (priv, pub) = EddsaPublicKey.randEdsaKeyPair()
    207         val valid_req = SubjectRequest(
    208             exchangePayto,
    209             TransferType.reserve,
    210             false,
    211             TalerAmount("KUDOS:1"),
    212             PublicKeyAlg.EdDSA,
    213             pub,
    214             pub,
    215             EddsaSignature.rand()
    216         ).sign(priv)
    217 
    218         val now = TalerTimestamp(Instant.now())
    219         val req = Unregistration(
    220             now,
    221             pub,
    222             EddsaSignature.rand()
    223         ).sign(priv)
    224       
    225         // Unknown
    226         client.post("/taler-prepared-transfer/unregistration") {
    227             json(req)
    228         }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
    229         
    230         // Know
    231         client.post("/taler-prepared-transfer/registration") {
    232             json(valid_req)
    233         }.assertOkJson<SubjectResult>()
    234         client.post("/taler-prepared-transfer/unregistration") {
    235             json(req)
    236         }.assertNoContent()
    237 
    238         // Idempotent
    239         client.post("/taler-prepared-transfer/unregistration") {
    240             json(req)
    241         }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
    242 
    243         // Bad signature
    244         client.post("/taler-prepared-transfer/unregistration") {
    245             json(
    246                 Unregistration(
    247                     now,
    248                     pub,
    249                     EddsaSignature.rand()
    250                 )
    251             )
    252         }.assertForbidden(TalerErrorCode.BANK_BAD_SIGNATURE)
    253 
    254         // Old timestamp
    255         client.post("/taler-prepared-transfer/unregistration") {
    256             json(
    257                 Unregistration(
    258                     TalerTimestamp(Instant.now().minusSeconds(1000000)),
    259                     pub,
    260                     EddsaSignature.rand()
    261                 ).sign(priv)
    262             )
    263         }.assertConflict(TalerErrorCode.BANK_OLD_TIMESTAMP)
    264 
    265         // Unknown bounce
    266         assertBalance("customer", "+KUDOS:0")
    267         assertBalance("exchange", "+KUDOS:0")
    268         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce
    269         assertBalance("customer", "+KUDOS:0")
    270         assertBalance("exchange", "+KUDOS:0")
    271 
    272         // Pending bounced after deletion
    273         val newKey = EddsaPublicKey.randEdsaKey()
    274         client.post("/taler-prepared-transfer/registration") {
    275             json(valid_req.copy(account_pub=newKey, recurrent=true).sign(priv))
    276         }.assertOkJson<SubjectResult>()
    277         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // reserve
    278         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending
    279         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending
    280         assertBalance("customer", "-KUDOS:3")
    281         assertBalance("exchange", "+KUDOS:3")
    282         client.post("/taler-prepared-transfer/unregistration") {
    283             json(req)
    284         }.assertNoContent()
    285         assertBalance("customer", "-KUDOS:1")
    286         assertBalance("exchange", "+KUDOS:1")
    287     }
    288 }