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