WireGatewayApiTest.kt (17850B)
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_amount" to "CHF:44" 345 "type" to "reserve" 346 "alg" to "EdDSA" 347 "account_pub" to pub 348 "authorization_pub" to pub 349 "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) 350 "recurrent" to false 351 } 352 }.assertOkJson<SubjectResult>() 353 val valid_req = obj { 354 "amount" to "CHF:44" 355 key to pub 356 "debit_account" to grothoffPayto 357 } 358 359 authRoutine(HttpMethod.Post, "/taler-wire-gateway/admin/$path") 360 361 // Check OK 362 client.postA("/taler-wire-gateway/admin/$path") { 363 json(valid_req) 364 }.assertOk() 365 366 when (type) { 367 IncomingType.reserve -> { 368 // Trigger conflict due to reused reserve_pub 369 client.postA("/taler-wire-gateway/admin/$path") { 370 json(valid_req) 371 }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) 372 } 373 IncomingType.kyc -> { 374 // Non conflict on reuse 375 client.postA("/taler-wire-gateway/admin/$path") { 376 json(valid_req) 377 }.assertOk() 378 } 379 IncomingType.map -> { 380 // Trigger conflict due to reused authorization_pub 381 client.postA("/taler-wire-gateway/admin/$path") { 382 json(valid_req) 383 }.assertConflict(TalerErrorCode.BANK_TRANSFER_MAPPING_REUSED) 384 // Trigger conflict due to unknown authorization_pub 385 client.postA("/taler-wire-gateway/admin/$path") { 386 json(valid_req) { 387 key to EddsaPublicKey.randEdsaKey() 388 } 389 }.assertConflict(TalerErrorCode.BANK_TRANSFER_MAPPING_UNKNOWN) 390 } 391 } 392 393 // Currency mismatch 394 client.postA("/taler-wire-gateway/admin/$path") { 395 json(valid_req) { "amount" to "EUR:33" } 396 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 397 398 // Bad BASE32 reserve_pub 399 client.postA("/taler-wire-gateway/admin/$path") { 400 json(valid_req) { 401 key to "I love chocolate" 402 } 403 }.assertBadRequest() 404 405 // Bad BASE32 len reserve_pub 406 client.postA("/taler-wire-gateway/admin/$path") { 407 json(valid_req) { 408 key to Base32Crockford.encode(ByteArray(31).rand()) 409 } 410 }.assertBadRequest() 411 412 // Bad payto kind 413 client.postA("/taler-wire-gateway/admin/$path") { 414 json(valid_req) { 415 "debit_account" to "payto://x-taler-bank/bank.hostname.test/bar" 416 } 417 }.assertBadRequest() 418 } 419 420 // POST /taler-wire-gateway/admin/add-incoming 421 @Test 422 fun addIncoming() = serverSetup { 423 talerAddIncomingRoutine(IncomingType.reserve) 424 } 425 426 // POST /taler-wire-gateway/admin/add-kycauth 427 @Test 428 fun addKycAuth() = serverSetup { 429 talerAddIncomingRoutine(IncomingType.kyc) 430 } 431 432 // POST /taler-wire-gateway/admin/add-mapped 433 @Test 434 fun addMapped() = serverSetup { 435 talerAddIncomingRoutine(IncomingType.map) 436 } 437 438 @Test 439 fun addIncomingMix() = serverSetup { db -> 440 addIncoming("CHF:1") 441 addKyc("CHF:2") 442 talerableIn(db, amount = "CHF:3") 443 talerableKycIn(db, amount = "CHF:4") 444 talerablePreparedIn(db, amount = "CHF:5") 445 client.getA("/taler-wire-gateway/history/incoming?limit=25").assertOkJson<IncomingHistory> { 446 assertEquals(5, it.incoming_transactions.size) 447 it.incoming_transactions.forEachIndexed { i, tx -> 448 assertEquals(TalerAmount("CHF:${i+1}"), tx.amount) 449 if (i % 2 == 1) { 450 val tmp = assertIs<IncomingKycAuthTransaction>(tx) 451 if (i < 4) { 452 assertNull(tmp.authorization_pub) 453 assertNull(tmp.authorization_sig) 454 } else { 455 assertNotNull(tmp.authorization_pub) 456 assertNotNull(tmp.authorization_sig) 457 } 458 } else { 459 val tmp = assertIs<IncomingReserveTransaction>(tx) 460 if (i < 4) { 461 assertNull(tmp.authorization_pub) 462 assertNull(tmp.authorization_sig) 463 } else { 464 assertNotNull(tmp.authorization_pub) 465 assertNotNull(tmp.authorization_sig) 466 } 467 } 468 469 } 470 } 471 } 472 473 // POST /taler-wire-gateway/account/check 474 @Test 475 fun accountCheck() = serverSetup { 476 client.getA("/taler-wire-gateway/account/check").assertNotImplemented() 477 } 478 479 @Test 480 fun noApi() = serverSetup("mini.conf") { 481 client.get("/taler-wire-gateway/config").assertNotImplemented() 482 } 483 484 @Test 485 fun auth() = serverSetup("auth.conf") { 486 authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming", false) 487 client.get("/taler-wire-gateway/history/incoming") { 488 basicAuth("username", "password") 489 }.assertNoContent() 490 } 491 }