WireGatewayApiTest.kt (13520B)
1 /* 2 * This file is part of LibEuFin. 3 * Copyright (C) 2023-2025 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 // Trigger conflict due to reused request_uid 61 client.postA("/taler-wire-gateway/transfer") { 62 json(valid_req) { 63 "wtid" to ShortHashCode.rand() 64 "exchange_base_url" to "http://different-exchange.example.com/" 65 } 66 }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) 67 68 // Trigger conflict due to reused wtid 69 client.postA("/taler-wire-gateway/transfer") { 70 json(valid_req) { 71 "request_uid" to HashCode.rand() 72 } 73 }.assertConflict(TalerErrorCode.BANK_TRANSFER_WTID_REUSED) 74 75 // Currency mismatch 76 client.postA("/taler-wire-gateway/transfer") { 77 json(valid_req) { 78 "amount" to "EUR:33" 79 } 80 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 81 82 // Bad BASE32 wtid 83 client.postA("/taler-wire-gateway/transfer") { 84 json(valid_req) { 85 "wtid" to "I love chocolate" 86 } 87 }.assertBadRequest() 88 89 // Bad BASE32 len wtid 90 client.postA("/taler-wire-gateway/transfer") { 91 json(valid_req) { 92 "wtid" to Base32Crockford.encode(ByteArray(31).rand()) 93 } 94 }.assertBadRequest() 95 96 // Bad BASE32 request_uid 97 client.postA("/taler-wire-gateway/transfer") { 98 json(valid_req) { 99 "request_uid" to "I love chocolate" 100 } 101 }.assertBadRequest() 102 103 // Bad BASE32 len wtid 104 client.postA("/taler-wire-gateway/transfer") { 105 json(valid_req) { 106 "request_uid" to Base32Crockford.encode(ByteArray(65).rand()) 107 } 108 }.assertBadRequest() 109 110 // Missing receiver-name 111 client.postA("/taler-wire-gateway/transfer") { 112 json(valid_req) { 113 "credit_account" to "payto://iban/CH7389144832588726658" 114 } 115 }.assertBadRequest() 116 117 // Bad payto kind 118 client.postA("/taler-wire-gateway/transfer") { 119 json(valid_req) { 120 "credit_account" to "payto://x-taler-bank/bank.hostname.test/bar?receiver-name=Mr+Tom" 121 } 122 }.assertBadRequest() 123 124 // Bad baseURL 125 for (bad in sequenceOf("not-a-url", "file://not.http.com/", "no.transport.com/", "https://not.a/base/url")) { 126 client.postA("/taler-wire-gateway/transfer") { 127 json(valid_req) { 128 "exchange_base_url" to bad 129 } 130 }.assertBadRequest() 131 } 132 } 133 134 // GET /taler-wire-gateway/transfers/{ROW_ID} 135 @Test 136 fun transferById() = serverSetup { 137 val wtid = ShortHashCode.rand() 138 val valid_req = obj { 139 "request_uid" to HashCode.rand() 140 "amount" to "CHF:55" 141 "exchange_base_url" to "http://exchange.example.com/" 142 "wtid" to wtid 143 "credit_account" to grothoffPayto 144 } 145 146 authRoutine(HttpMethod.Get, "/taler-wire-gateway/transfers/1") 147 148 val resp = client.postA("/taler-wire-gateway/transfer") { 149 json(valid_req) 150 }.assertOkJson<TransferResponse>() 151 152 // Check OK 153 client.getA("/taler-wire-gateway/transfers/${resp.row_id}") 154 .assertOkJson<TransferStatus> { tx -> 155 assertEquals(TransferStatusState.pending, tx.status) 156 assertEquals(TalerAmount("CHF:55"), tx.amount) 157 assertEquals("http://exchange.example.com/", tx.origin_exchange_url) 158 assertEquals(wtid, tx.wtid) 159 assertEquals(resp.timestamp, tx.timestamp) 160 } 161 162 // Check unknown transaction 163 client.getA("/taler-wire-gateway/transfers/42") 164 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 165 } 166 167 // GET /accounts/{USERNAME}/taler-wire-gateway/transfers 168 @Test 169 fun transferPage() = serverSetup { db -> 170 authRoutine(HttpMethod.Get, "/taler-wire-gateway/transfers") 171 172 client.getA("/taler-wire-gateway/transfers").assertNoContent() 173 174 repeat(6) { 175 client.postA("/taler-wire-gateway/transfer") { 176 json { 177 "request_uid" to HashCode.rand() 178 "amount" to "CHF:55" 179 "exchange_base_url" to "http://exchange.example.com/" 180 "wtid" to ShortHashCode.rand() 181 "credit_account" to grothoffPayto 182 } 183 }.assertOkJson<TransferResponse>() 184 db.initiated.batch(Instant.now(), randEbicsId(), false) 185 } 186 client.getA("/taler-wire-gateway/transfers") 187 .assertOkJson<TransferList> { 188 assertEquals(6, it.transfers.size) 189 assertEquals( 190 it, 191 client.getA("/taler-wire-gateway/transfers?status=pending").assertOkJson<TransferList>() 192 ) 193 } 194 client.getA("/taler-wire-gateway/transfers?status=success").assertNoContent() 195 196 db.initiated.batchSubmissionSuccess(1, Instant.now(), "ORDER1") 197 db.initiated.batchSubmissionFailure(2, Instant.now(), "Failure") 198 db.initiated.batchSubmissionFailure(3, Instant.now(), "Failure") 199 client.getA("/taler-wire-gateway/transfers?status=transient_failure").assertOkJson<TransferList> { 200 assertEquals(2, it.transfers.size) 201 } 202 client.getA("/taler-wire-gateway/transfers?status=pending").assertOkJson<TransferList> { 203 assertEquals(4, it.transfers.size) 204 } 205 } 206 207 // GET /taler-wire-gateway/history/incoming 208 @Test 209 fun historyIncoming() = serverSetup { db -> 210 authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming") 211 historyRoutine<IncomingHistory>( 212 url = "/taler-wire-gateway/history/incoming", 213 ids = { it.incoming_transactions.map { it.row_id } }, 214 registered = listOf( 215 // Reserve transactions using clean add incoming logic 216 { addIncoming("CHF:12") }, 217 218 // Reserve transactions using raw bank transaction logic 219 { talerableIn(db) }, 220 { talerableCompletedIn(db) }, 221 222 // KYC transactions using clean add incoming logic 223 { addKyc("CHF:12") }, 224 225 // KYC transactions using raw bank transaction logic 226 { talerableKycIn(db) }, 227 ), 228 ignored = listOf( 229 // Ignore malformed incoming transaction 230 { registerIn(db) }, 231 232 // Ignore malformed incomplete 233 { registerIncompleteIn(db) }, 234 235 // Ignore malformed completed 236 { registerCompletedIn(db) }, 237 238 // Ignore incompleted 239 { talerableIncompleteIn(db) }, 240 241 // Ignore outgoing transaction 242 { talerableOut(db) }, 243 ) 244 ) 245 } 246 247 // GET /taler-wire-gateway/history/outgoing 248 @Test 249 fun historyOutgoing() = serverSetup { db -> 250 authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/outgoing") 251 historyRoutine<OutgoingHistory>( 252 url = "/taler-wire-gateway/history/outgoing", 253 ids = { it.outgoing_transactions.map { it.row_id } }, 254 registered = listOf( 255 // Transfer using raw bank transaction logic 256 { talerableOut(db) }, 257 ), 258 ignored = listOf( 259 // Ignore pending transfers 260 { transfer() }, 261 262 // Ignore manual incoming transaction 263 { talerableIn(db) }, 264 265 // Ignore malformed incoming transaction 266 { registerIn(db) }, 267 268 // Ignore malformed outgoing transaction 269 { registerOutgoingPayment(db, genOutPay("ignored")) }, 270 ) 271 ) 272 } 273 274 suspend fun ApplicationTestBuilder.talerAddIncomingRoutine(type: IncomingType) { 275 val (path, key) = when (type) { 276 IncomingType.reserve -> Pair("add-incoming", "reserve_pub") 277 IncomingType.kyc -> Pair("add-kycauth", "account_pub") 278 IncomingType.wad -> throw UnsupportedOperationException() 279 } 280 val valid_req = obj { 281 "amount" to "CHF:44" 282 key to EddsaPublicKey.rand() 283 "debit_account" to grothoffPayto 284 } 285 286 authRoutine(HttpMethod.Post, "/taler-wire-gateway/admin/$path") 287 288 // Check OK 289 client.postA("/taler-wire-gateway/admin/$path") { 290 json(valid_req) 291 }.assertOk() 292 293 if (type == IncomingType.reserve) { 294 // Trigger conflict due to reused reserve_pub 295 client.postA("/taler-wire-gateway/admin/$path") { 296 json(valid_req) 297 }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) 298 } else if (type == IncomingType.kyc) { 299 // Non conflict on reuse 300 client.postA("/taler-wire-gateway/admin/$path") { 301 json(valid_req) 302 }.assertOk() 303 } 304 305 // Currency mismatch 306 client.postA("/taler-wire-gateway/admin/$path") { 307 json(valid_req) { "amount" to "EUR:33" } 308 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 309 310 // Bad BASE32 reserve_pub 311 client.postA("/taler-wire-gateway/admin/$path") { 312 json(valid_req) { 313 key to "I love chocolate" 314 } 315 }.assertBadRequest() 316 317 // Bad BASE32 len reserve_pub 318 client.postA("/taler-wire-gateway/admin/$path") { 319 json(valid_req) { 320 key to Base32Crockford.encode(ByteArray(31).rand()) 321 } 322 }.assertBadRequest() 323 324 // Bad payto kind 325 client.postA("/taler-wire-gateway/admin/$path") { 326 json(valid_req) { 327 "debit_account" to "payto://x-taler-bank/bank.hostname.test/bar" 328 } 329 }.assertBadRequest() 330 } 331 332 // POST /taler-wire-gateway/admin/add-incoming 333 @Test 334 fun addIncoming() = serverSetup { 335 talerAddIncomingRoutine(IncomingType.reserve) 336 } 337 338 // POST /taler-wire-gateway/admin/add-kycauth 339 @Test 340 fun addKycAuth() = serverSetup { 341 talerAddIncomingRoutine(IncomingType.kyc) 342 } 343 344 @Test 345 fun addIncomingMix() = serverSetup { db -> 346 addIncoming("CHF:1") 347 addKyc("CHF:2") 348 talerableIn(db, amount = "CHF:3") 349 talerableKycIn(db, amount = "CHF:4") 350 client.getA("/taler-wire-gateway/history/incoming?limit=25").assertOkJson<IncomingHistory> { 351 assertEquals(4, it.incoming_transactions.size) 352 it.incoming_transactions.forEachIndexed { i, tx -> 353 assertEquals(TalerAmount("CHF:${i+1}"), tx.amount) 354 if (i % 2 == 1) { 355 assertIs<IncomingKycAuthTransaction>(tx) 356 } else { 357 assertIs<IncomingReserveTransaction>(tx) 358 } 359 } 360 } 361 } 362 363 // POST /taler-wire-gateway/account/check 364 @Test 365 fun accountCheck() = serverSetup { 366 client.getA("/taler-wire-gateway/account/check").assertNotImplemented() 367 } 368 369 @Test 370 fun noApi() = serverSetup("mini.conf") { 371 client.get("/taler-wire-gateway/config").assertNotImplemented() 372 } 373 374 @Test 375 fun auth() = serverSetup("auth.conf") { 376 authRoutine(HttpMethod.Get, "/taler-wire-gateway/history/incoming", false) 377 client.get("/taler-wire-gateway/history/incoming") { 378 basicAuth("username", "password") 379 }.assertNoContent() 380 } 381 }