libeufin

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

PreparedTransferApiTest.kt (14428B)


      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 = obj {
     41             "credit_account" to exchangePayto
     42             "credit_amount" to amount
     43             "type" to "reserve"
     44             "alg" to "EdDSA"
     45             "account_pub" to pub
     46             "authorization_pub" to pub
     47             "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv)
     48             "recurrent" to false
     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)
     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)
     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) {
     71                 "type" to "kyc"
     72             }
     73         }.assertOkJson<SubjectResult> {
     74             assertEquals(it.subjects[1], simpleSubject)
     75             val uriSubject = assertIs<TransferSubject.Uri>(it.subjects[0])
     76             assertNotEquals(subjects[0], uriSubject)
     77         }
     78 
     79         // Recurrent only has simple subject
     80         client.post("/taler-prepared-transfer/registration") {
     81             json(valid_req) {
     82                 "recurrent" to true
     83             }
     84         }.assertOkJson<SubjectResult> {
     85             assertEquals(it.subjects, listOf(simpleSubject))
     86         }
     87 
     88         // Bad signature
     89         client.post("/taler-prepared-transfer/registration") {
     90             json(valid_req) {
     91                 "authorization_sig" to EddsaSignature.rand()
     92             }
     93         }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE)
     94 
     95         // Not exchange
     96         client.post("/taler-prepared-transfer/registration") {
     97             json(valid_req) {
     98                 "credit_account" to merchantPayto
     99             }
    100         }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE)
    101 
    102         // Unknown account
    103         client.post("/taler-prepared-transfer/registration") {
    104             json(valid_req) {
    105                 "credit_account" to unknownPayto
    106             }
    107         }.assertConflict(TalerErrorCode.BANK_UNKNOWN_CREDITOR)
    108 
    109         assertBalance("customer", "+KUDOS:0")
    110         assertBalance("exchange", "+KUDOS:0")
    111 
    112         // Non recurrent accept on then bounce
    113         client.post("/taler-prepared-transfer/registration") {
    114             json(valid_req) {
    115                 "type" to "reserve"
    116             }
    117         }.assertOkJson<SubjectResult> {
    118             val uuid = (it.subjects[0] as? TransferSubject.Uri)!!.uri.substringAfterLast('/')
    119             client.postA("/accounts/customer/withdrawals/$uuid/confirm").assertNoContent() // reserve
    120             tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce
    121             tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce
    122             assertBalance("customer", "-KUDOS:1")
    123             assertBalance("exchange", "+KUDOS:1")
    124         }
    125 
    126         // Withdrawal is aborted on completion
    127         client.post("/taler-prepared-transfer/registration") {
    128             json(valid_req) {
    129                 "type" to "kyc"
    130             }
    131         }.assertOkJson<SubjectResult> {
    132             val uuid = (it.subjects[0] as? TransferSubject.Uri)!!.uri.substringAfterLast('/')
    133             println("UUID $uuid")
    134             tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // kyc
    135             tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce
    136             client.postA("/accounts/customer/withdrawals/$uuid/confirm")
    137                 .assertConflict(TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT) // aborted
    138             assertBalance("customer", "-KUDOS:2")
    139             assertBalance("exchange", "+KUDOS:2")
    140         }
    141 
    142         // Recurrent accept one and delay others
    143         val newKey = EddsaPublicKey.randEdsaKey()
    144         client.post("/taler-prepared-transfer/registration") {
    145             json(valid_req) {
    146                 "account_pub" to newKey
    147                 "authorization_sig" to CryptoUtil.eddsaSign(newKey.raw, priv)
    148                 "recurrent" to true
    149             }
    150         }
    151         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // reserve
    152         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending
    153         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending
    154         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending
    155         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending
    156         assertBalance("customer", "-KUDOS:7")
    157         assertBalance("exchange", "+KUDOS:7")
    158 
    159         // Complete pending on recurrent update
    160         val kycKey = EddsaPublicKey.randEdsaKey()
    161         client.post("/taler-prepared-transfer/registration") {
    162             json(valid_req) {
    163                 "type" to "kyc"
    164                 "account_pub" to kycKey
    165                 "authorization_sig" to CryptoUtil.eddsaSign(kycKey.raw, priv)
    166                 "recurrent" to true
    167             }
    168         }.assertOkJson<SubjectResult>()
    169         client.post("/taler-prepared-transfer/registration") {
    170             json(valid_req) {
    171                 "type" to "reserve"
    172                 "account_pub" to kycKey
    173                 "authorization_sig" to CryptoUtil.eddsaSign(kycKey.raw, priv)
    174                 "recurrent" to true
    175             }
    176         }.assertOkJson<SubjectResult>()
    177         assertBalance("customer", "-KUDOS:7")
    178         assertBalance("exchange", "+KUDOS:7")
    179 
    180         // Kyc key reuse keep pending ones
    181         tx("customer", "KUDOS:1", "exchange", "Taler KYC:$kycKey")
    182         assertBalance("customer", "-KUDOS:8")
    183         assertBalance("exchange", "+KUDOS:8")
    184 
    185         // Switching to non recurrent cancel pending
    186         client.post("/taler-prepared-transfer/registration") {
    187             json(valid_req) {
    188                 "type" to "kyc"
    189                 "account_pub" to kycKey
    190                 "authorization_sig" to CryptoUtil.eddsaSign(kycKey.raw, priv)
    191             }
    192         }.assertOkJson<SubjectResult>()
    193         assertBalance("customer", "-KUDOS:6")
    194         assertBalance("exchange", "+KUDOS:6")
    195 
    196         // Check authorization field in incoming history
    197         val (testPriv, testAuth) = EddsaPublicKey.randEdsaKeyPair()
    198         val testKey = EddsaPublicKey.randEdsaKey()
    199         val testSig = CryptoUtil.eddsaSign(testKey.raw, testPriv)
    200         val qr = subjectFmtQrBill(testAuth)
    201         client.post("/taler-prepared-transfer/registration") {
    202             json(valid_req) {
    203                 "account_pub" to testKey
    204                 "authorization_pub" to testAuth
    205                 "authorization_sig" to testSig
    206                 "recurrent" to true
    207             }
    208         }.assertOkJson<SubjectResult>()
    209         tx("customer", "KUDOS:0.1", "exchange", "Taler MAP:$testAuth")
    210         tx("customer", "KUDOS:0.1", "exchange", "Taler MAP:$testAuth")
    211         tx("customer", "KUDOS:0.1", "exchange", "Taler MAP:$testAuth")
    212         client.post("/taler-prepared-transfer/registration") {
    213             json(valid_req) {
    214                 "type" to "kyc"
    215                 "account_pub" to testKey
    216                 "authorization_pub" to testAuth
    217                 "authorization_sig" to testSig
    218                 "recurrent" to true
    219             }
    220         }.assertOkJson<SubjectResult>()
    221         val otherPub = EddsaPublicKey.randEdsaKey()
    222         val otherSig = CryptoUtil.eddsaSign(otherPub.raw, testPriv)
    223         client.post("/taler-prepared-transfer/registration") {
    224             json(valid_req) {
    225                 "type" to "reserve"
    226                 "account_pub" to otherPub
    227                 "authorization_pub" to testAuth
    228                 "authorization_sig" to otherSig
    229                 "recurrent" to true
    230             }
    231         }.assertOkJson<SubjectResult>()
    232         val lastPub = EddsaPublicKey.randEdsaKey()
    233         tx("customer", "KUDOS:0.1", "exchange", "Taler $lastPub")
    234         tx("customer", "KUDOS:0.1", "exchange", "Taler KYC:$lastPub")
    235         val history = client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?limit=-5")
    236             .assertOkJson<IncomingHistory>().incoming_transactions.map {
    237                 when (it) {
    238                     is IncomingKycAuthTransaction -> Triple(it.account_pub, it.authorization_pub, it.authorization_sig)
    239                     is IncomingReserveTransaction -> Triple(it.reserve_pub, it.authorization_pub, it.authorization_sig)
    240                     else -> throw UnsupportedOperationException()
    241                 }
    242             }
    243         assertContentEquals(history, listOf(
    244             Triple(lastPub, null, null),
    245             Triple(lastPub, null, null),
    246             Triple(otherPub, testAuth, otherSig),
    247             Triple(testKey, testAuth, testSig),
    248             Triple(testKey, testAuth, testSig)
    249         ))
    250     }
    251 
    252     // DELETE /taler-prepared-transfer/registration
    253     @Test
    254     fun unregistration() = bankSetup {
    255         val (priv, pub) = EddsaPublicKey.randEdsaKeyPair()
    256         val valid_req = obj {
    257             "credit_account" to exchangePayto
    258             "credit_amount" to "KUDOS:1"
    259             "type" to "reserve"
    260             "alg" to "EdDSA"
    261             "account_pub" to pub
    262             "authorization_pub" to pub
    263             "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv)
    264             "recurrent" to false
    265         }
    266 
    267         // Unknown
    268         client.post("/taler-prepared-transfer/unregistration") {
    269             val now = Instant.now().toString()
    270             json {
    271                 "timestamp" to now
    272                 "authorization_pub" to pub
    273                 "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv)
    274             }
    275         }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
    276                 
    277         // Know
    278         client.post("/taler-prepared-transfer/registration") {
    279             json(valid_req)
    280         }.assertOkJson<SubjectResult>()
    281         client.post("/taler-prepared-transfer/unregistration") {
    282             val now = Instant.now().toString()
    283             json {
    284                 "timestamp" to now
    285                 "authorization_pub" to pub
    286                 "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv)
    287             }
    288         }.assertNoContent()
    289 
    290         // Idempotent
    291         client.post("/taler-prepared-transfer/unregistration") {
    292             val now = Instant.now().toString()
    293             json {
    294                 "timestamp" to now
    295                 "authorization_pub" to pub
    296                 "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv)
    297             }
    298         }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
    299 
    300         // Bad signature
    301         client.post("/taler-prepared-transfer/unregistration") {
    302             val now = Instant.now().toString()
    303             json {
    304                 "timestamp" to now
    305                 "authorization_pub" to pub
    306                 "authorization_sig" to CryptoUtil.eddsaSign("lol".toByteArray(), priv)
    307             }
    308         }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE)
    309 
    310         // Old timestamp
    311         client.post("/taler-prepared-transfer/unregistration") {
    312             val now = Instant.now().minusSeconds(1000000).toString()
    313             json {
    314                 "timestamp" to now
    315                 "authorization_pub" to pub
    316                 "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv)
    317             }
    318         }.assertConflict(TalerErrorCode.BANK_OLD_TIMESTAMP)
    319 
    320         // Unknown bounce
    321         assertBalance("customer", "+KUDOS:0")
    322         assertBalance("exchange", "+KUDOS:0")
    323         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce
    324         assertBalance("customer", "+KUDOS:0")
    325         assertBalance("exchange", "+KUDOS:0")
    326 
    327         // Pending bounced after deletion
    328         val newKey = EddsaPublicKey.randEdsaKey()
    329         client.post("/taler-prepared-transfer/registration") {
    330             json(valid_req) {
    331                 "account_pub" to newKey
    332                 "authorization_sig" to CryptoUtil.eddsaSign(newKey.raw, priv)
    333                 "recurrent" to true
    334             }
    335         }.assertOkJson<SubjectResult>()
    336         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // reserve
    337         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending
    338         tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending
    339         assertBalance("customer", "-KUDOS:3")
    340         assertBalance("exchange", "+KUDOS:3")
    341         client.post("/taler-prepared-transfer/unregistration") {
    342             val now = Instant.now().toString()
    343             json {
    344                 "timestamp" to now
    345                 "authorization_pub" to pub
    346                 "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv)
    347             }
    348         }.assertNoContent()
    349         assertBalance("customer", "-KUDOS:1")
    350         assertBalance("exchange", "+KUDOS:1")
    351     }
    352 }