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