WireGatewayApiTest.kt (21712B)
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.http.* 21 import io.ktor.server.testing.* 22 import io.ktor.client.request.* 23 import org.junit.Test 24 import tech.libeufin.common.* 25 import tech.libeufin.common.crypto.CryptoUtil 26 import tech.libeufin.common.test.* 27 import kotlin.test.* 28 29 class WireGatewayApiTest { 30 // GET /accounts/{USERNAME}/taler-wire-gateway/config 31 @Test 32 fun config() = bankSetup { 33 client.get("/accounts/merchant/taler-wire-gateway/config").assertOk() 34 } 35 36 // POST /accounts/{USERNAME}/taler-wire-gateway/transfer 37 @Test 38 fun transfer() = bankSetup { 39 val valid_req = obj { 40 "request_uid" to HashCode.rand() 41 "amount" to "KUDOS:55" 42 "exchange_base_url" to "http://exchange.example.com/" 43 "wtid" to ShortHashCode.rand() 44 "credit_account" to merchantPayto.canonical 45 } 46 47 authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/transfer", valid_req) 48 49 // Checking exchange debt constraint. 50 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 51 json(valid_req) 52 }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) 53 54 // Giving debt allowance and checking the OK case. 55 setMaxDebt("exchange", "KUDOS:1000") 56 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 57 json(valid_req) 58 }.assertOk() 59 60 // check idempotency 61 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 62 json(valid_req) 63 }.assertOk() 64 65 val with_metadata = obj(valid_req) { 66 "request_uid" to HashCode.rand() 67 "metadata" to "ID" 68 "wtid" to ShortHashCode.rand() 69 } 70 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 71 json(with_metadata) 72 }.assertOk() 73 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 74 json(with_metadata) 75 }.assertOk() 76 77 // Malformed metadata 78 listOf("bad_id", "bad id", "bad@id.com", "A".repeat(41)).forEach { 79 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 80 json(valid_req) { 81 "metadata" to it 82 } 83 }.assertBadRequest() 84 } 85 86 val new_req = obj(valid_req) { 87 "request_uid" to HashCode.rand() 88 "wtid" to ShortHashCode.rand() 89 "credit_account" to adminPayto 90 } 91 92 // Check conversion bounce 93 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 94 json(new_req) 95 }.assertOk() 96 97 // check idempotency 98 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 99 json(new_req) 100 }.assertOk() 101 102 103 // Trigger conflict due to reused request_uid 104 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 105 json(valid_req) { 106 "wtid" to ShortHashCode.rand() 107 "exchange_base_url" to "http://different-exchange.example.com/" 108 } 109 }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) 110 111 // Trigger conflict due to reused wtid 112 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 113 json(valid_req) { 114 "request_uid" to HashCode.rand() 115 } 116 }.assertConflict(TalerErrorCode.BANK_TRANSFER_WTID_REUSED) 117 118 // Currency mismatch 119 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 120 json(valid_req) { 121 "amount" to "EUR:33" 122 } 123 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 124 125 // Same account 126 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 127 json(valid_req) { 128 "request_uid" to HashCode.rand() 129 "wtid" to ShortHashCode.rand() 130 "credit_account" to exchangePayto 131 } 132 }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE) 133 134 // Bad BASE32 wtid 135 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 136 json(valid_req) { 137 "wtid" to "I love chocolate" 138 } 139 }.assertBadRequest() 140 141 // Bad BASE32 len wtid 142 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 143 json(valid_req) { 144 "wtid" to randBase32Crockford(31) 145 } 146 }.assertBadRequest() 147 148 // Bad BASE32 request_uid 149 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 150 json(valid_req) { 151 "request_uid" to "I love chocolate" 152 } 153 }.assertBadRequest() 154 155 // Bad BASE32 len wtid 156 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 157 json(valid_req) { 158 "request_uid" to randBase32Crockford(65) 159 } 160 }.assertBadRequest() 161 162 // Bad baseURL 163 for (bad in sequenceOf("not-a-url", "file://not.http.com/", "no.transport.com/", "https://not.a/base/url")) { 164 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 165 json(valid_req) { 166 "exchange_base_url" to bad 167 } 168 }.assertBadRequest() 169 } 170 } 171 172 @Test 173 fun transferNoConversion() = bankSetup("test_no_conversion.conf") { 174 val valid_req = obj { 175 "request_uid" to HashCode.rand() 176 "amount" to "KUDOS:55" 177 "exchange_base_url" to "http://exchange.example.com/" 178 "wtid" to ShortHashCode.rand() 179 "credit_account" to merchantPayto.canonical 180 } 181 setMaxDebt("exchange", "KUDOS:1000") 182 183 // Transfer works for common accounts 184 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 185 json(valid_req) 186 }.assertOk() 187 188 // But fails to admin accounts 189 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 190 json(valid_req) { 191 "credit_account" to adminPayto 192 "request_uid" to HashCode.rand() 193 "wtid" to ShortHashCode.rand() 194 } 195 }.assertConflict(TalerErrorCode.BANK_ADMIN_CREDITOR) 196 } 197 198 // GET /accounts/{USERNAME}/taler-wire-gateway/transfers/{ROW_ID} 199 @Test 200 fun transferById() = bankSetup { 201 var wtid = ShortHashCode.rand() 202 val valid_req = obj { 203 "request_uid" to HashCode.rand() 204 "amount" to "KUDOS:0.12" 205 "exchange_base_url" to "http://exchange.example.com/" 206 "wtid" to wtid 207 "credit_account" to merchantPayto.canonical 208 } 209 210 authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/transfers/1", requireExchange = true) 211 212 val resp = client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 213 json(valid_req) 214 }.assertOkJson<TransferResponse>() 215 216 // Check OK 217 client.getA("/accounts/exchange/taler-wire-gateway/transfers/${resp.row_id}") 218 .assertOkJson<TransferStatus> { tx -> 219 assertEquals(TransferStatusState.success, tx.status) 220 assertEquals(TalerAmount("KUDOS:0.12"), tx.amount) 221 assertEquals("http://exchange.example.com/", tx.origin_exchange_url) 222 assertNull(tx.metadata) 223 assertEquals(wtid, tx.wtid) 224 assertEquals(resp.timestamp, tx.timestamp) 225 } 226 227 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 228 json(valid_req) { 229 "request_uid" to HashCode.rand() 230 "metadata" to "ID" 231 "wtid" to ShortHashCode.rand() 232 } 233 }.assertOkJson<TransferResponse> { 234 client.getA("/accounts/exchange/taler-wire-gateway/transfers/${it.row_id}") 235 .assertOkJson<TransferStatus> { tx -> 236 assertEquals(tx.metadata, "ID") 237 } 238 } 239 240 // Unknown account 241 wtid = ShortHashCode.rand() 242 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 243 json(valid_req) { 244 "request_uid" to HashCode.rand() 245 "wtid" to wtid 246 "credit_account" to unknownPayto 247 } 248 }.assertOkJson<TransferResponse> { resp -> 249 client.getA("/accounts/exchange/taler-wire-gateway/transfers/${resp.row_id}") 250 .assertOkJson<TransferStatus> { tx -> 251 assertEquals(TransferStatusState.permanent_failure, tx.status) 252 assertEquals(TalerAmount("KUDOS:0.12"), tx.amount) 253 assertEquals("http://exchange.example.com/", tx.origin_exchange_url) 254 assertEquals(wtid, tx.wtid) 255 assertEquals(resp.timestamp, tx.timestamp) 256 } 257 } 258 // Check unknown transaction 259 client.getA("/accounts/exchange/taler-wire-gateway/transfers/42") 260 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 261 // Check another user's transaction 262 client.getA("/accounts/merchant/taler-wire-gateway/transfers/${resp.row_id}") 263 .assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE) 264 } 265 266 // GET /accounts/{USERNAME}/taler-wire-gateway/transfers 267 @Test 268 fun transferPage() = bankSetup { 269 authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/transfers", requireExchange = true) 270 271 client.getA("/accounts/exchange/taler-wire-gateway/transfers").assertNoContent() 272 273 repeat(5) { 274 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 275 json { 276 "request_uid" to HashCode.rand() 277 "amount" to "KUDOS:0.12" 278 "exchange_base_url" to "http://exchange.example.com/" 279 "wtid" to ShortHashCode.rand() 280 "credit_account" to merchantPayto 281 } 282 }.assertOkJson<TransferResponse>() 283 } 284 client.getA("/accounts/exchange/taler-wire-gateway/transfers") 285 .assertOkJson<TransferList> { 286 assertEquals(5, it.transfers.size) 287 assertEquals( 288 it, 289 client.getA("/accounts/exchange/taler-wire-gateway/transfers?status=success").assertOkJson<TransferList>() 290 ) 291 } 292 client.getA("/accounts/exchange/taler-wire-gateway/transfers?status=pending").assertNoContent() 293 client.getA("/accounts/exchange/taler-wire-gateway/transfers?status=permanent_failure").assertNoContent() 294 295 client.postA("/accounts/exchange/taler-wire-gateway/transfer") { 296 json { 297 "request_uid" to HashCode.rand() 298 "amount" to "KUDOS:0.12" 299 "exchange_base_url" to "http://exchange.example.com/" 300 "wtid" to ShortHashCode.rand() 301 "credit_account" to unknownPayto 302 } 303 }.assertOkJson<TransferResponse>() 304 client.getA("/accounts/exchange/taler-wire-gateway/transfers").assertOkJson<TransferList> { 305 assertEquals(6, it.transfers.size) 306 } 307 client.getA("/accounts/exchange/taler-wire-gateway/transfers?status=success").assertOkJson<TransferList> { 308 assertEquals(5, it.transfers.size) 309 } 310 client.getA("/accounts/exchange/taler-wire-gateway/transfers?status=permanent_failure").assertOkJson<TransferList> { 311 assertEquals(1, it.transfers.size) 312 } 313 } 314 315 // GET /accounts/{USERNAME}/taler-wire-gateway/history/incoming 316 @Test 317 fun historyIncoming() = bankSetup { 318 // Give Foo reasonable debt allowance: 319 setMaxDebt("merchant", "KUDOS:1000") 320 authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/history/incoming", requireExchange = true) 321 historyRoutine<IncomingHistory>( 322 url = "/accounts/exchange/taler-wire-gateway/history/incoming", 323 ids = { it.incoming_transactions.map { it.row_id } }, 324 registered = listOf( 325 // Reserve transactions using clean add incoming logic 326 { addIncoming("KUDOS:10") }, 327 328 // Reserve transactions using raw bank transaction logic 329 { tx("merchant", "KUDOS:10", "exchange", "history test with ${EddsaPublicKey.randEdsaKey()} reserve pub") }, 330 331 // Reserve transactions using withdraw logic 332 { withdrawal("KUDOS:9") }, 333 334 // KYC transaction using clean add incoming logic 335 { addKyc("KUDOS:2") }, 336 337 // KYC transactions using raw bank transaction logic 338 { tx("merchant", "KUDOS:2", "exchange", "history test with KYC:${EddsaPublicKey.randEdsaKey()} account pub") }, 339 ), 340 ignored = listOf( 341 // Ignore malformed incoming transaction 342 { tx("merchant", "KUDOS:10", "exchange", "ignored") }, 343 344 // Ignore malformed outgoing transaction 345 { tx("exchange", "KUDOS:10", "merchant", "ignored") }, 346 ) 347 ) 348 } 349 350 // GET /accounts/{USERNAME}/taler-wire-gateway/history/outgoing 351 @Test 352 fun historyOutgoing() = bankSetup { 353 setMaxDebt("exchange", "KUDOS:1000000") 354 authRoutine(HttpMethod.Get, "/accounts/merchant/taler-wire-gateway/history/outgoing", requireExchange = true) 355 historyRoutine<OutgoingHistory>( 356 url = "/accounts/exchange/taler-wire-gateway/history/outgoing", 357 ids = { it.outgoing_transactions.map { it.row_id } }, 358 registered = listOf( 359 // Transactions using clean add incoming logic 360 { transfer("KUDOS:10") }, 361 362 // And with metadata 363 { transfer("KUDOS:12", metadata = "CON:ID") } 364 ), 365 ignored = listOf( 366 // Failed transfer 367 { transfer("KUDOS:10", unknownPayto) }, 368 369 // Ignore manual outgoing transaction 370 { tx("exchange", "KUDOS:10", "merchant", "${ShortHashCode.rand()} http://exchange.example.com/") }, 371 372 // Ignore malformed incoming transaction 373 { tx("merchant", "KUDOS:10", "exchange", "ignored") }, 374 375 // Ignore malformed outgoing transaction 376 { tx("exchange", "KUDOS:10", "merchant", "ignored") }, 377 ) 378 ) 379 assertContentEquals( 380 client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?limit=2") 381 .assertOkJson<OutgoingHistory>() 382 .outgoing_transactions 383 .map { it.amount.toString() to it.metadata } 384 ,listOf( 385 "KUDOS:10" to null, 386 "KUDOS:12" to "CON:ID", 387 ) 388 ) 389 } 390 391 suspend fun ApplicationTestBuilder.talerAddIncomingRoutine(type: IncomingType) { 392 val (path, key) = when (type) { 393 IncomingType.reserve -> Pair("add-incoming", "reserve_pub") 394 IncomingType.kyc -> Pair("add-kycauth", "account_pub") 395 IncomingType.map -> Pair("add-mapped", "authorization_pub") 396 } 397 398 val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() 399 client.post("/accounts/exchange/taler-prepared-transfer/registration") { 400 json { 401 "credit_amount" to "KUDOS:44" 402 "type" to "reserve" 403 "alg" to "EdDSA" 404 "account_pub" to pub 405 "authorization_pub" to pub 406 "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) 407 "recurrent" to false 408 } 409 }.assertOkJson<SubjectResult>() 410 val valid_req = obj { 411 "amount" to "KUDOS:44" 412 key to pub 413 "debit_account" to merchantPayto.canonical 414 } 415 416 authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/admin/$path", valid_req, requireAdmin = true) 417 418 // Checking exchange debt constraint. 419 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 420 json(valid_req) 421 }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) 422 423 // Giving debt allowance and checking the OK case. 424 setMaxDebt("merchant", "KUDOS:1000") 425 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 426 json(valid_req) 427 }.assertOk() 428 429 when (type) { 430 IncomingType.reserve -> { 431 // Trigger conflict due to reused reserve_pub 432 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 433 json(valid_req) 434 }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) 435 } 436 IncomingType.kyc -> { 437 // Non conflict on reuse 438 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 439 json(valid_req) 440 }.assertOk() 441 } 442 IncomingType.map -> { 443 // Trigger conflict due to reused authorization_pub 444 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 445 json(valid_req) 446 }.assertConflict(TalerErrorCode.BANK_TRANSFER_MAPPING_REUSED) 447 // Trigger conflict due to unknown authorization_pub 448 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 449 json(valid_req) { 450 key to EddsaPublicKey.randEdsaKey() 451 } 452 }.assertConflict(TalerErrorCode.BANK_TRANSFER_MAPPING_UNKNOWN) 453 } 454 } 455 456 // Currency mismatch 457 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 458 json(valid_req) { "amount" to "EUR:33" } 459 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 460 461 // Unknown account 462 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 463 json(valid_req) { 464 key to EddsaPublicKey.randEdsaKey() 465 "debit_account" to unknownPayto 466 } 467 }.assertConflict(TalerErrorCode.BANK_UNKNOWN_DEBTOR) 468 469 // Same account 470 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 471 json(valid_req) { 472 key to EddsaPublicKey.randEdsaKey() 473 "debit_account" to exchangePayto 474 } 475 }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE) 476 477 // Bad BASE32 reserve_pub 478 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 479 json(valid_req) { 480 key to "I love chocolate" 481 } 482 }.assertBadRequest() 483 484 // Bad BASE32 len reserve_pub 485 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 486 json(valid_req) { 487 key to randBase32Crockford(31) 488 } 489 }.assertBadRequest() 490 } 491 492 // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming 493 @Test 494 fun addIncoming() = bankSetup { 495 talerAddIncomingRoutine(IncomingType.reserve) 496 } 497 498 // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-kycauth 499 @Test 500 fun addKycAuth() = bankSetup { 501 talerAddIncomingRoutine(IncomingType.kyc) 502 } 503 504 // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-mapped 505 @Test 506 fun addMapped() = bankSetup { 507 talerAddIncomingRoutine(IncomingType.map) 508 } 509 510 @Test 511 fun addIncomingMix() = bankSetup { 512 addIncoming("KUDOS:1") 513 addKyc("KUDOS:2") 514 tx("merchant", "KUDOS:3", "exchange", "test with ${EddsaPublicKey.randEdsaKey()} reserve pub") 515 tx("merchant", "KUDOS:4", "exchange", "test with KYC:${EddsaPublicKey.randEdsaKey()} account pub") 516 client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?limit=25").assertOkJson<IncomingHistory> { 517 assertEquals(4, it.incoming_transactions.size) 518 it.incoming_transactions.forEachIndexed { i, tx -> 519 assertEquals(TalerAmount("KUDOS:${i+1}"), tx.amount) 520 if (i % 2 == 1) { 521 assertIs<IncomingKycAuthTransaction>(tx) 522 } else { 523 assertIs<IncomingReserveTransaction>(tx) 524 } 525 } 526 } 527 } 528 529 // POST /taler-wire-gateway/account/check 530 @Test 531 fun accountCheck() = bankSetup { 532 client.getA("/accounts/exchange/taler-wire-gateway/account/check").assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MISSING) 533 client.getA("/accounts/exchange/taler-wire-gateway/account/check?account=$unknownPayto").assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) 534 client.getA("/accounts/exchange/taler-wire-gateway/account/check?account=$merchantPayto").assertOkJson<AccountInfo>() 535 } 536 }