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 }