WireGatewayApiTest.kt (21744B)
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("/taler-prepared-transfer/registration") { 400 json { 401 "credit_account" to exchangePayto 402 "credit_amount" to "KUDOS:44" 403 "type" to "reserve" 404 "alg" to "EdDSA" 405 "account_pub" to pub 406 "authorization_pub" to pub 407 "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) 408 "recurrent" to false 409 } 410 }.assertOkJson<SubjectResult>() 411 val valid_req = obj { 412 "amount" to "KUDOS:44" 413 key to pub 414 "debit_account" to merchantPayto.canonical 415 } 416 417 authRoutine(HttpMethod.Post, "/accounts/merchant/taler-wire-gateway/admin/$path", valid_req, requireAdmin = true) 418 419 // Checking exchange debt constraint. 420 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 421 json(valid_req) 422 }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) 423 424 // Giving debt allowance and checking the OK case. 425 setMaxDebt("merchant", "KUDOS:1000") 426 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 427 json(valid_req) 428 }.assertOk() 429 430 when (type) { 431 IncomingType.reserve -> { 432 // Trigger conflict due to reused reserve_pub 433 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 434 json(valid_req) 435 }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) 436 } 437 IncomingType.kyc -> { 438 // Non conflict on reuse 439 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 440 json(valid_req) 441 }.assertOk() 442 } 443 IncomingType.map -> { 444 // Trigger conflict due to reused authorization_pub 445 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 446 json(valid_req) 447 }.assertConflict(TalerErrorCode.BANK_TRANSFER_MAPPING_REUSED) 448 // Trigger conflict due to unknown authorization_pub 449 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 450 json(valid_req) { 451 key to EddsaPublicKey.randEdsaKey() 452 } 453 }.assertConflict(TalerErrorCode.BANK_TRANSFER_MAPPING_UNKNOWN) 454 } 455 } 456 457 // Currency mismatch 458 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 459 json(valid_req) { "amount" to "EUR:33" } 460 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 461 462 // Unknown account 463 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 464 json(valid_req) { 465 key to EddsaPublicKey.randEdsaKey() 466 "debit_account" to unknownPayto 467 } 468 }.assertConflict(TalerErrorCode.BANK_UNKNOWN_DEBTOR) 469 470 // Same account 471 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 472 json(valid_req) { 473 key to EddsaPublicKey.randEdsaKey() 474 "debit_account" to exchangePayto 475 } 476 }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE) 477 478 // Bad BASE32 reserve_pub 479 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 480 json(valid_req) { 481 key to "I love chocolate" 482 } 483 }.assertBadRequest() 484 485 // Bad BASE32 len reserve_pub 486 client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { 487 json(valid_req) { 488 key to randBase32Crockford(31) 489 } 490 }.assertBadRequest() 491 } 492 493 // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming 494 @Test 495 fun addIncoming() = bankSetup { 496 talerAddIncomingRoutine(IncomingType.reserve) 497 } 498 499 // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-kycauth 500 @Test 501 fun addKycAuth() = bankSetup { 502 talerAddIncomingRoutine(IncomingType.kyc) 503 } 504 505 // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-mapped 506 @Test 507 fun addMapped() = bankSetup { 508 talerAddIncomingRoutine(IncomingType.map) 509 } 510 511 @Test 512 fun addIncomingMix() = bankSetup { 513 addIncoming("KUDOS:1") 514 addKyc("KUDOS:2") 515 tx("merchant", "KUDOS:3", "exchange", "test with ${EddsaPublicKey.randEdsaKey()} reserve pub") 516 tx("merchant", "KUDOS:4", "exchange", "test with KYC:${EddsaPublicKey.randEdsaKey()} account pub") 517 client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?limit=25").assertOkJson<IncomingHistory> { 518 assertEquals(4, it.incoming_transactions.size) 519 it.incoming_transactions.forEachIndexed { i, tx -> 520 assertEquals(TalerAmount("KUDOS:${i+1}"), tx.amount) 521 if (i % 2 == 1) { 522 assertIs<IncomingKycAuthTransaction>(tx) 523 } else { 524 assertIs<IncomingReserveTransaction>(tx) 525 } 526 } 527 } 528 } 529 530 // POST /taler-wire-gateway/account/check 531 @Test 532 fun accountCheck() = bankSetup { 533 client.getA("/accounts/exchange/taler-wire-gateway/account/check").assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MISSING) 534 client.getA("/accounts/exchange/taler-wire-gateway/account/check?account=$unknownPayto").assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) 535 client.getA("/accounts/exchange/taler-wire-gateway/account/check?account=$merchantPayto").assertOkJson<AccountInfo>() 536 } 537 }