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