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