CoreBankApiTest.kt (93326B)
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.client.statement.* 22 import io.ktor.http.* 23 import io.ktor.server.testing.* 24 import kotlinx.serialization.json.JsonElement 25 import org.junit.Test 26 import tech.libeufin.bank.* 27 import tech.libeufin.bank.auth.TOKEN_PREFIX 28 import tech.libeufin.common.* 29 import tech.libeufin.common.crypto.CryptoUtil 30 import tech.libeufin.common.db.* 31 import tech.libeufin.common.test.* 32 import java.time.Duration 33 import java.time.Instant 34 import java.util.* 35 import kotlin.test.* 36 37 class CoreBankSecurityTest { 38 @Test 39 fun passwordUpdate() = bankSetup { db -> 40 suspend fun currentHash(): String { 41 return db.serializable( 42 "SELECT password_hash FROM customers WHERE username='customer'" 43 ) { 44 one { 45 it.getString(1) 46 } 47 } 48 } 49 50 // Set outdated hash 51 val password = "customer-password" 52 val pwh = CryptoUtil.hashStringSHA256(password).encodeBase64() 53 val hash = "sha256\$$pwh" 54 db.serializable( 55 "UPDATE customers SET password_hash=? WHERE username='customer'" 56 ) { 57 bind(hash) 58 executeUpdate() 59 } 60 assertEquals(hash, currentHash()) 61 62 // Check hash is updated 63 client.getA("/accounts/customer").assertOk() 64 val newHash = currentHash() 65 assert(hash != newHash) 66 67 // Check hash stay the same 68 client.getA("/accounts/customer").assertOk() 69 assertEquals(newHash, currentHash()) 70 } 71 } 72 73 class CoreBankConfigTest { 74 // GET /config 75 @Test 76 fun config() = bankSetup { 77 client.get("/config").assertOk() 78 } 79 80 // GET /monitor 81 @Test 82 fun monitor() = bankSetup { 83 authRoutine(HttpMethod.Get, "/monitor", requireAdmin = true) 84 // Check OK 85 client.getAdmin("/monitor?timeframe=day&which=25").assertOk() 86 client.getAdmin("/monitor?timeframe=day=which=25").assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) 87 } 88 } 89 90 class CoreBankTokenApiTest { 91 // POST /accounts/USERNAME/token 92 @Test 93 fun post() = bankSetup { db -> 94 authRoutine(HttpMethod.Post, "/accounts/merchant/token") 95 96 // Unknown account 97 client.post("/accounts/merchant/token") { 98 basicAuth("unknown", "password") 99 }.assertUnauthorized() 100 101 // Wrong password 102 client.post("/accounts/merchant/token") { 103 basicAuth("merchant", "wrong-password") 104 }.assertUnauthorized() 105 106 // Wrong account 107 client.post("/accounts/merchant/token") { 108 basicAuth("exchange", "merchant-password") 109 }.assertUnauthorized() 110 111 // New default token 112 client.postPw("/accounts/merchant/token") { 113 json { "scope" to "readonly" } 114 }.assertOkJson<TokenSuccessResponse> { 115 // Checking that the token lifetime defaulted to 24 hours. 116 val token = db.token.access(Base32Crockford.decode(it.access_token.removePrefix(TOKEN_PREFIX)), Instant.now()) 117 val lifeTime = Duration.between(token!!.creationTime, token.expirationTime) 118 assertEquals(Duration.ofDays(1), lifeTime) 119 } 120 121 // Check default duration 122 client.postPw("/accounts/merchant/token") { 123 json { "scope" to "readonly" } 124 }.assertOkJson<TokenSuccessResponse> { 125 // Checking that the token lifetime defaulted to 24 hours. 126 val token = db.token.access(Base32Crockford.decode(it.access_token.removePrefix(TOKEN_PREFIX)), Instant.now()) 127 val lifeTime = Duration.between(token!!.creationTime, token.expirationTime) 128 assertEquals(Duration.ofDays(1), lifeTime) 129 } 130 131 // Check valid refresh scope 132 for ((fromScope, toScope) in listOf( 133 "readwrite" to "readwrite", 134 "readonly" to "readonly", 135 "revenue" to "revenue", 136 "readwrite" to "readonly", 137 "readwrite" to "revenue", 138 "readonly" to "revenue", 139 )) { 140 client.postPw("/accounts/merchant/token") { 141 json { 142 "scope" to fromScope 143 "refreshable" to true 144 } 145 }.assertOkJson<TokenSuccessResponse> { 146 val token = it.access_token 147 client.post("/accounts/merchant/token") { 148 headers[HttpHeaders.Authorization] = "Bearer $token" 149 json { "scope" to toScope } 150 }.assertOk() 151 } 152 } 153 154 // Check invalid refresh scope 155 for ((fromScope, toScope) in listOf( 156 "readonly" to "readwrite", 157 "revenue" to "readonly", 158 "revenue" to "readwrite" 159 )) { 160 client.postPw("/accounts/merchant/token") { 161 json { 162 "scope" to fromScope 163 "refreshable" to true 164 } 165 }.assertOkJson<TokenSuccessResponse> { 166 val token = it.access_token 167 client.post("/accounts/merchant/token") { 168 headers[HttpHeaders.Authorization] = "Bearer $token" 169 json { "scope" to toScope } 170 }.assertForbidden(TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT) 171 } 172 } 173 174 // Check no refreshable 175 client.postPw("/accounts/merchant/token") { 176 json { 177 "scope" to "readonly" 178 } 179 }.assertOkJson<TokenSuccessResponse> { 180 val token = it.access_token 181 client.post("/accounts/merchant/token") { 182 headers[HttpHeaders.Authorization] = "Bearer $token" 183 json { "scope" to "readonly" } 184 }.assertForbidden(TalerErrorCode.GENERIC_TOKEN_PERMISSION_INSUFFICIENT) 185 } 186 187 // Check 'forever' case. 188 client.postPw("/accounts/merchant/token") { 189 json { 190 "scope" to "readonly" 191 "duration" to obj { 192 "d_us" to "forever" 193 } 194 } 195 }.assertOkJson<TokenSuccessResponse> { 196 assertEquals(Instant.MAX, it.expiration.instant) 197 } 198 199 // Check too big or invalid durations 200 client.postPw("/accounts/merchant/token") { 201 json { 202 "scope" to "readonly" 203 "duration" to obj { 204 "d_us" to "invalid" 205 } 206 } 207 }.assertBadRequest() 208 client.postPw("/accounts/merchant/token") { 209 json { 210 "scope" to "readonly" 211 "duration" to obj { 212 "d_us" to Long.MAX_VALUE 213 } 214 } 215 }.assertBadRequest() 216 client.postPw("/accounts/merchant/token") { 217 json { 218 "scope" to "readonly" 219 "duration" to obj { 220 "d_us" to -1 221 } 222 } 223 }.assertBadRequest() 224 } 225 226 @Test 227 fun post2FA() = bankSetup { db -> 228 // Setup a known phone 2FA 229 client.patchA("/accounts/merchant") { 230 json { 231 "contact_data" to obj { 232 "phone" to "+12345" 233 } 234 "tan_channel" to "sms" 235 } 236 }.assertChallenge().assertNoContent() 237 238 // Check creating a token requires to solve an unauthenticated challenge 239 val challenge = client.postPw("/accounts/merchant/token") { 240 json { "scope" to "readonly" } 241 }.assertAcceptedJson<ChallengeResponse>().challenges[0] 242 client.post("/accounts/merchant/challenge/${challenge.challenge_id}") 243 .assertOk() 244 assertEquals("REDACTED", challenge.tan_info) // Check phone number is hidden 245 val code = tanCode("+12345") 246 client.post("/accounts/merchant/challenge/${challenge.challenge_id}/confirm") { 247 json { "tan" to code } 248 }.assertNoContent() 249 client.postPw("/accounts/merchant/token") { 250 headers[TALER_CHALLENGE_IDS] = "${challenge.challenge_id}" 251 json { "scope" to "readonly" } 252 }.assertOkJson<TokenSuccessResponse>() 253 } 254 255 @Test 256 fun locked() = bankSetup { db -> 257 // Setup a known phone 2FA 258 client.patchA("/accounts/merchant") { 259 json { 260 "contact_data" to obj { 261 "phone" to "+12345" 262 } 263 "tan_channel" to "sms" 264 } 265 }.assertChallenge().assertNoContent() 266 267 suspend fun blockAccount() { 268 var counter = MAX_TOKEN_CREATION_ATTEMPTS + 1 269 while (counter > 0) { 270 val challenge = client.postPw("/accounts/merchant/token") { 271 json { "scope" to "readonly" } 272 }.assertAcceptedJson<ChallengeResponse>().challenges[0] 273 client.post("/accounts/merchant/challenge/${challenge.challenge_id}") 274 .assertOk() 275 while (counter > 0) { 276 val error = client.post("/accounts/merchant/challenge/${challenge.challenge_id}/confirm"){ 277 json { "tan" to "bad code" } 278 }.json<TalerError>() 279 counter -= 1 280 when (error.code) { 281 TalerErrorCode.BANK_TAN_CHALLENGE_FAILED.code -> continue 282 TalerErrorCode.BANK_TAN_RATE_LIMITED.code, TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED.code -> break 283 else -> throw Exception("$error") 284 } 285 } 286 } 287 client.postPw("/accounts/merchant/token") { 288 json { "scope" to "readonly" } 289 }.assertForbidden(TalerErrorCode.BANK_ACCOUNT_LOCKED) 290 } 291 292 blockAccount() 293 294 // Check token still works 295 client.getA("/accounts/merchant").assertOkJson<AccountData> { 296 assertTrue(it.is_locked) 297 } 298 299 // Check admin can unlock 300 client.patchAdmin("/accounts/merchant/auth") { 301 json { 302 "new_password" to "merchant-password" 303 } 304 }.assertNoContent() 305 client.getA("/accounts/merchant").assertOkJson<AccountData> { 306 assertFalse(it.is_locked) 307 } 308 blockAccount() 309 310 // Check token can unlock 311 client.patchA("/accounts/merchant/auth") { 312 json { 313 "old_password" to "merchant-password" 314 "new_password" to "merchant-password" 315 } 316 }.assertChallenge().assertNoContent() 317 client.getA("/accounts/merchant").assertOkJson<AccountData> { 318 assertFalse(it.is_locked) 319 } 320 } 321 322 // DELETE /accounts/USERNAME/token 323 @Test 324 fun delete() = bankSetup { 325 val token = client.postPw("/accounts/merchant/token") { 326 json { "scope" to "readonly" } 327 }.assertOkJson<TokenSuccessResponse>().access_token 328 // Check OK 329 client.delete("/accounts/merchant/token") { 330 headers[HttpHeaders.Authorization] = "Bearer $token" 331 }.assertNoContent() 332 // Check token no longer work 333 client.delete("/accounts/merchant/token") { 334 headers[HttpHeaders.Authorization] = "Bearer $token" 335 }.assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN) 336 } 337 338 // DELETE /accounts/USERNAME/tokens/TOKEN_ID 339 @Test 340 fun deleteById() = bankSetup { 341 authRoutine(HttpMethod.Delete, "/accounts/merchant/tokens/1t", allowAdmin = true) 342 343 val token = client.postPw("/accounts/merchant/token") { 344 json { "scope" to "readonly" } 345 }.assertOkJson<TokenSuccessResponse>().access_token 346 // Check OK 347 client.deleteA("/accounts/merchant/tokens/2").assertNoContent() 348 client.deleteA("/accounts/merchant/tokens/2").assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 349 // Check token no longer work 350 client.delete("/accounts/merchant/token") { 351 headers[HttpHeaders.Authorization] = "Bearer $token" 352 }.assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN) 353 } 354 355 // GET /accounts/USERNAME/tokens 356 @Test 357 fun get() = bankSetup { 358 // Check OK 359 for (account in listOf("merchant", "customer")) { 360 client.getA("/accounts/$account/tokens").assertOkJson<TokenInfos> { 361 assertEquals(1, it.tokens.size) 362 } 363 } 364 client.postPw("/accounts/merchant/token") { 365 json { "scope" to "readonly" } 366 }.assertOk() 367 client.postPw("/accounts/merchant/token") { 368 json { "scope" to "readwrite" } 369 }.assertOk() 370 client.postPw("/accounts/customer/token") { 371 json { 372 "scope" to "revenue" 373 "description" to "description" 374 } 375 }.assertOk() 376 client.getA("/accounts/merchant/tokens").assertOkJson<TokenInfos> { 377 assertEquals(3, it.tokens.size) 378 for (token in it.tokens) { 379 assertNull(token.description) 380 } 381 } 382 client.getA("/accounts/customer/tokens").assertOkJson<TokenInfos> { 383 assertEquals(2, it.tokens.size) 384 assertEquals("description", it.tokens[0].description) 385 } 386 } 387 } 388 389 class CoreBankAccountsApiTest { 390 // POST /accounts 391 @Test 392 fun create() = bankSetup { 393 // Check generated payto 394 obj { 395 "username" to "john" 396 "password" to "password" 397 "name" to "John" 398 }.let { req -> 399 // Check Ok 400 val payto = client.post("/accounts") { 401 json(req) 402 }.assertOkJson<RegisterAccountResponse>().internal_payto_uri 403 // Check idempotency 404 client.post("/accounts") { 405 json(req) 406 }.assertOkJson<RegisterAccountResponse> { 407 assertEquals(payto, it.internal_payto_uri) 408 } 409 // Check idempotency with payto 410 client.post("/accounts") { 411 json(req) { 412 "payto_uri" to payto 413 } 414 }.assertOk() 415 // Check payto conflict 416 client.post("/accounts") { 417 json(req) { 418 "payto_uri" to IbanPayto.rand() 419 } 420 }.assertConflict(TalerErrorCode.BANK_REGISTER_USERNAME_REUSE) 421 } 422 423 // Check given payto 424 val payto = IbanPayto.rand() 425 val req = obj { 426 "username" to "foo" 427 "password" to "password" 428 "name" to "Jane" 429 "is_public" to true 430 "payto_uri" to payto 431 "is_taler_exchange" to true 432 } 433 // Check Ok 434 client.post("/accounts") { 435 json(req) 436 }.assertOkJson<RegisterAccountResponse> { 437 assertEquals(payto.full("Jane"), it.internal_payto_uri) 438 } 439 // Testing idempotency 440 client.post("/accounts") { 441 json(req) 442 }.assertOkJson<RegisterAccountResponse> { 443 assertEquals(payto.full("Jane"), it.internal_payto_uri) 444 } 445 // Check admin only debit_threshold 446 obj { 447 "username" to "bat" 448 "password" to "password" 449 "name" to "Bat" 450 "debit_threshold" to "KUDOS:42" 451 }.let { req -> 452 client.post("/accounts") { 453 json(req) 454 }.assertConflict(TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT) 455 client.postAdmin("/accounts") { 456 json(req) 457 }.assertOk() 458 } 459 460 // Check admin only conversion_rate_class_id 461 createConversionRateClass() 462 obj { 463 "username" to "bat2" 464 "password" to "password" 465 "name" to "Bat" 466 "conversion_rate_class_id" to 1 467 }.let { req -> 468 client.post("/accounts") { 469 json(req) 470 }.assertConflict(TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS) 471 client.postAdmin("/accounts") { 472 json(req) 473 }.assertOk() 474 } 475 476 // Check admin only tan_channel 477 obj { 478 "username" to "bat3" 479 "password" to "password" 480 "name" to "Bat" 481 "contact_data" to obj { 482 "phone" to "+456" 483 } 484 "tan_channel" to "sms" 485 }.let { req -> 486 client.post("/accounts") { 487 json(req) 488 }.assertConflict(TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL) 489 client.postAdmin("/accounts") { 490 json(req) 491 }.assertOk() 492 } 493 494 // Check both tan channels 495 client.postAdmin("/accounts") { 496 json { 497 "username" to "bat2" 498 "password" to "password" 499 "name" to "Bat" 500 "tan_channel" to "sms" 501 "tan_channels" to emptyList<String>() 502 } 503 }.assertBadRequest() 504 505 // Check tan info 506 val channels = listOf("sms", "email") 507 for (channel in channels) { 508 client.postAdmin("/accounts") { 509 json { 510 "username" to "bat2" 511 "password" to "password" 512 "name" to "Bat" 513 "tan_channel" to channel 514 } 515 }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO) 516 client.postAdmin("/accounts") { 517 json { 518 "username" to "bat2" 519 "password" to "password" 520 "name" to "Bat" 521 "tan_channels" to listOf(channel) 522 } 523 }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO) 524 } 525 client.postAdmin("/accounts") { 526 json { 527 "username" to "bat2" 528 "password" to "password" 529 "name" to "Bat" 530 "tan_channels" to channels 531 } 532 }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO) 533 534 // Check unknown conversion rate class 535 client.postAdmin("/accounts") { 536 json { 537 "username" to "new_account" 538 "password" to "password" 539 "name" to "New Account" 540 "conversion_rate_class_id" to 42 541 } 542 }.assertConflict(TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN) 543 544 // Reserved account 545 RESERVED_ACCOUNTS.forEach { 546 client.post("/accounts") { 547 json { 548 "username" to it 549 "password" to "password" 550 "name" to "John Smith" 551 } 552 }.assertConflict(TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT) 553 } 554 555 // Malformed username 556 listOf("bad@username", "long".repeat(50)).forEach { 557 client.post("/accounts") { 558 json { 559 "username" to it 560 "password" to "password" 561 "name" to "John Smith" 562 } 563 }.assertBadRequest() 564 } 565 566 // Non exchange account 567 client.post("/accounts") { 568 json { 569 "username" to "exchange" 570 "password" to "password" 571 "name" to "Exchange" 572 } 573 }.assertConflict(TalerErrorCode.END) 574 575 // Testing username conflict 576 client.post("/accounts") { 577 json(req) { 578 "name" to "Foo" 579 } 580 }.assertConflict(TalerErrorCode.BANK_REGISTER_USERNAME_REUSE) 581 // Testing payto conflict 582 client.post("/accounts") { 583 json(req) { 584 "username" to "bar" 585 } 586 }.assertConflict(TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE) 587 client.getAdmin("/accounts/bar").assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) 588 // Testing bad payto kind 589 client.post("/accounts") { 590 json(req) { 591 "username" to "bar" 592 "password" to "bar-password" 593 "name" to "Mr Bar" 594 "payto_uri" to "payto://x-taler-bank/bank.hostname.test/bar" 595 } 596 }.assertBadRequest() 597 // Testing short password 598 client.post("/accounts") { 599 json(req) { 600 "password" to "short" 601 } 602 }.assertConflict(TalerErrorCode.BANK_PASSWORD_TOO_SHORT) 603 // Testing long password 604 client.post("/accounts") { 605 json(req) { 606 "password" to "loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong-password" 607 } 608 }.assertConflict(TalerErrorCode.BANK_PASSWORD_TOO_LONG) 609 610 // Check cashout payto receiver name logic 611 client.post("/accounts") { 612 json { 613 "username" to "cashout_guess" 614 "password" to "cashout_guess-password" 615 "name" to "Mr Guess My Name" 616 "cashout_payto_uri" to payto 617 } 618 }.assertOk() 619 client.getA("/accounts/cashout_guess").assertOkJson<AccountData> { 620 assertEquals(payto.full("Mr Guess My Name"), it.cashout_payto_uri) 621 } 622 client.post("/accounts") { 623 json { 624 "username" to "cashout_keep" 625 "password" to "cashout_keep-password" 626 "name" to "Mr Keep My Name" 627 "cashout_payto_uri" to payto.full("Santa Claus") 628 } 629 }.assertOk() 630 client.getA("/accounts/cashout_keep").assertOkJson<AccountData> { 631 assertEquals(payto.full("Mr Keep My Name"), it.cashout_payto_uri) 632 } 633 634 // Check input restrictions 635 obj { 636 "username" to "username" 637 "password" to "password" 638 "name" to "Name" 639 }.let { req -> 640 client.post("/accounts") { 641 json(req) { "username" to "bad/username" } 642 }.assertBadRequest() 643 client.post("/accounts") { 644 json(req) { "username" to " spaces " } 645 }.assertBadRequest() 646 client.post("/accounts") { 647 json(req) { 648 "contact_data" to obj { 649 "phone" to " +456" 650 } 651 } 652 }.assertBadRequest() 653 client.post("/accounts") { 654 json(req) { 655 "contact_data" to obj { 656 "phone" to " test@mail.com" 657 } 658 } 659 }.assertBadRequest() 660 } 661 } 662 663 // Test account created with bonus 664 @Test 665 fun createBonus() = bankSetup(conf = "test_bonus.conf") { 666 val req = obj { 667 "username" to "foo" 668 "password" to "password-xyz" 669 "name" to "Mallory" 670 } 671 672 setMaxDebt("admin", "KUDOS:10000") 673 674 // Check ok 675 repeat(100) { 676 client.postAdmin("/accounts") { 677 json(req) { 678 "username" to "foo$it" 679 } 680 }.assertOk() 681 assertBalance("foo$it", "+KUDOS:100") 682 } 683 assertBalance("admin", "-KUDOS:10000") 684 685 // Check insufficient fund 686 client.postAdmin("/accounts") { 687 json(req) { 688 "username" to "bar" 689 } 690 }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) 691 client.getAdmin("/accounts/bar").assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) 692 } 693 694 // Test admin-only account creation 695 @Test 696 fun createRestricted() = bankSetup(conf = "test_restrict.conf") { 697 authRoutine(HttpMethod.Post, "/accounts", requireAdmin = true) 698 client.postAdmin("/accounts") { 699 json { 700 "username" to "baz" 701 "password" to "password-xyz" 702 "name" to "Mallory" 703 } 704 }.assertOk() 705 } 706 707 // Test admin-only account creation 708 @Test 709 fun createTanErr() = bankSetup(conf = "test_tan_err.conf") { 710 client.postAdmin("/accounts") { 711 json { 712 "username" to "baz" 713 "password" to "xyz" 714 "name" to "Mallory" 715 "tan_channel" to "email" 716 } 717 }.assertConflict(TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED) 718 } 719 720 // POST /accounts 721 @Test 722 fun createNoCheck() = bankSetup("test_no_password_check.conf") { 723 // Testing short password 724 client.post("/accounts") { 725 json { 726 "username" to "short" 727 "name" to "John Smith" 728 "password" to "short" 729 } 730 }.assertOk() 731 // Testing long password 732 client.post("/accounts") { 733 json { 734 "username" to "long" 735 "name" to "Jane Smith" 736 "password" to "loooooooooooooooooooooooooooooooooooooooooooooooooooooooong-password" 737 } 738 }.assertOk() 739 } 740 741 // DELETE /accounts/USERNAME 742 @Test 743 fun delete() = bankSetup { db -> 744 authRoutine(HttpMethod.Delete, "/accounts/merchant", allowAdmin = true) 745 746 // Reserved account 747 RESERVED_ACCOUNTS.forEach { 748 client.deleteAdmin("/accounts/$it") 749 .assertConflict(TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT) 750 } 751 client.deleteA("/accounts/exchange") 752 .assertConflict(TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT) 753 754 client.post("/accounts") { 755 json { 756 "username" to "john" 757 "password" to "john-password" 758 "name" to "John" 759 "payto_uri" to genTmpPayTo() 760 } 761 }.assertOk() 762 fillTanInfo("john") 763 // Fail to delete, due to a non-zero balance. 764 tx("customer", "KUDOS:1", "john") 765 client.deleteA("/accounts/john") 766 .assertConflict(TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO) 767 // Successful deletion 768 tx("john", "KUDOS:1", "customer") 769 client.deleteA("/accounts/john") 770 .assertChallenge() 771 .assertNoContent() 772 // Account no longer exists 773 client.deleteA("/accounts/john") 774 .assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN) 775 client.deleteAdmin("/accounts/john") 776 .assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) 777 } 778 779 @Test 780 fun softDelete() = bankSetup { db -> 781 // Create all kind of operations 782 val token = client.postPw("/accounts/customer/token") { 783 json { "scope" to "readonly" } 784 }.assertOkJson<TokenSuccessResponse>().access_token 785 val tx_id = client.postA("/accounts/customer/transactions") { 786 json { 787 "payto_uri" to "$exchangePayto?message=payout" 788 "amount" to "KUDOS:0.3" 789 } 790 }.assertOkJson<TransactionCreateResponse>().row_id 791 val withdrawal_id = client.postA("/accounts/customer/withdrawals") { 792 json { "amount" to "KUDOS:9.0" } 793 }.assertOkJson<BankAccountCreateWithdrawalResponse>().withdrawal_id 794 fillCashoutInfo("customer") 795 val cashout_id = client.postA("/accounts/customer/cashouts") { 796 json { 797 "request_uid" to ShortHashCode.rand() 798 "amount_debit" to "KUDOS:1" 799 "amount_credit" to convert("KUDOS:1") 800 } 801 }.assertOkJson<CashoutResponse>().cashout_id 802 fillTanInfo("customer") 803 client.postA("/accounts/customer/transactions") { 804 json { 805 "payto_uri" to "$exchangePayto?message=payout" 806 "amount" to "KUDOS:0.3" 807 } 808 }.assertAcceptedJson<ChallengeResponse>() 809 810 // Delete account 811 tx("merchant", "KUDOS:1", "customer") 812 assertBalance("customer", "+KUDOS:0") 813 client.deleteA("/accounts/customer") 814 .assertChallenge() 815 .assertNoContent() 816 817 // Check account can no longer username 818 client.delete("/accounts/customer/token") { 819 headers[HttpHeaders.Authorization] = "Bearer $token" 820 }.assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN) 821 client.getA("/accounts/customer/transactions/$tx_id") 822 .assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN) 823 client.getA("/accounts/customer/cashouts/$cashout_id") 824 .assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN) 825 client.postA("/accounts/customer/withdrawals/$withdrawal_id/confirm") 826 .assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN) 827 828 // But admin can still see existing operations 829 client.getAdmin("/accounts/customer/transactions/$tx_id") 830 .assertOkJson<BankAccountTransactionInfo>() 831 client.getAdmin("/accounts/customer/cashouts/$cashout_id") 832 .assertOkJson<CashoutStatusResponse>() 833 client.get("/withdrawals/$withdrawal_id") 834 .assertOkJson<WithdrawalPublicInfo>() 835 836 // GC 837 db.gc.collect(Instant.now(), Duration.ZERO, Duration.ZERO, Duration.ZERO) 838 client.getAdmin("/accounts/customer/transactions/$tx_id") 839 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 840 client.getAdmin("/accounts/customer/cashouts/$cashout_id") 841 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 842 client.get("/withdrawals/$withdrawal_id") 843 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 844 } 845 846 // Test admin-only account deletion 847 @Test 848 fun deleteRestricted() = bankSetup(conf = "test_restrict.conf") { 849 authRoutine(HttpMethod.Post, "/accounts", requireAdmin = true) 850 // Exchange is still restricted 851 client.deleteAdmin("/accounts/exchange") { 852 }.assertConflict(TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT) 853 } 854 855 // Test delete exchange account 856 @Test 857 fun deleteNoConversion() = bankSetup(conf = "test_no_conversion.conf") { 858 // Exchange is no longer restricted 859 client.deleteA("/accounts/exchange").assertNoContent() 860 } 861 862 suspend fun ApplicationTestBuilder.checkAdminOnly( 863 req: JsonElement, 864 error: TalerErrorCode 865 ) { 866 // Check restricted 867 client.patchA("/accounts/merchant") { 868 json(req) 869 }.assertConflict(error) 870 // Check admin always can 871 client.patchAdmin("/accounts/merchant") { 872 json(req) 873 }.assertNoContent() 874 // Check idempotent 875 client.patchA("/accounts/merchant") { 876 json(req) 877 }.assertNoContent() 878 } 879 880 // PATCH /accounts/USERNAME 881 @Test 882 fun reconfig() = bankSetup { 883 authRoutine(HttpMethod.Patch, "/accounts/merchant", allowAdmin = true) 884 885 // Check tan info 886 val channels = listOf("sms", "email") 887 for (channel in channels) { 888 client.patchA("/accounts/merchant") { 889 json { "tan_channel" to channel } 890 }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO) 891 client.patchA("/accounts/merchant") { 892 json { "tan_channels" to listOf(channel) } 893 }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO) 894 } 895 client.patchA("/accounts/merchant") { 896 json { "tan_channels" to channels } 897 }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO) 898 899 // Successful attempt now 900 val cashout = IbanPayto.rand() 901 val req = obj { 902 "cashout_payto_uri" to cashout 903 "name" to "Roger" 904 "is_public" to true 905 "contact_data" to obj { 906 "phone" to "+99" 907 "email" to "foo@example.com" 908 } 909 } 910 client.patchA("/accounts/merchant") { 911 json(req) 912 }.assertNoContent() 913 // Checking idempotence 914 client.patchA("/accounts/merchant") { 915 json(req) 916 }.assertNoContent() 917 918 checkAdminOnly( 919 obj(req) { "debit_threshold" to "KUDOS:100" }, 920 TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT 921 ) 922 createConversionRateClass() 923 checkAdminOnly( 924 obj(req) { "conversion_rate_class_id" to 1 }, 925 TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS 926 ) 927 928 // Check unknown conversion rate class 929 client.patchAdmin("/accounts/merchant") { 930 json(req) { "conversion_rate_class_id" to 42} 931 }.assertConflict(TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN) 932 933 // Check currency 934 client.patchAdmin("/accounts/merchant") { 935 json(req) { "debit_threshold" to "EUR:100" } 936 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 937 938 // Check patch 939 client.getA("/accounts/merchant").assertOkJson<AccountData> { obj -> 940 assertEquals("Roger", obj.name) 941 assertEquals(cashout.full(obj.name), obj.cashout_payto_uri) 942 assertEquals("+99", obj.contact_data?.phone?.get()) 943 assertEquals("foo@example.com", obj.contact_data?.email?.get()) 944 assertEquals(TalerAmount("KUDOS:100"), obj.debit_threshold) 945 assert(obj.is_public) 946 assert(!obj.is_taler_exchange) 947 } 948 949 // Check keep values when there is no changes 950 client.patchA("/accounts/merchant") { 951 json { } 952 }.assertNoContent() 953 client.getA("/accounts/merchant").assertOkJson<AccountData> { obj -> 954 assertEquals("Roger", obj.name) 955 assertEquals(cashout.full(obj.name), obj.cashout_payto_uri) 956 assertEquals("+99", obj.contact_data?.phone?.get()) 957 assertEquals("foo@example.com", obj.contact_data?.email?.get()) 958 assertEquals(TalerAmount("KUDOS:100"), obj.debit_threshold) 959 assert(obj.is_public) 960 assert(!obj.is_taler_exchange) 961 } 962 963 // Admin cannot be public 964 client.patchA("/accounts/admin") { 965 json { 966 "is_public" to true 967 } 968 }.assertConflict(TalerErrorCode.END) 969 970 // Exchange must be exchange 971 client.patchA("/accounts/exchange") { 972 json { 973 "is_taler_exchange" to false 974 } 975 }.assertConflict(TalerErrorCode.END) 976 977 // Check cashout payto receiver name logic 978 client.post("/accounts") { 979 json { 980 "username" to "cashout" 981 "password" to "cashout-password" 982 "name" to "Mr Cashout Cashout" 983 } 984 }.assertOk() 985 val canonical = Payto.parse(cashout.canonical).expectIban() 986 for ((cashout, name, expect) in listOf( 987 Triple(cashout.canonical, null, canonical.full("Mr Cashout Cashout")), 988 Triple(cashout.canonical, "New name", canonical.full("New name")), 989 Triple(cashout.full("Full name"), null, cashout.full("New name")), 990 Triple(cashout.full("Full second name"), "Another name", cashout.full("Another name")) 991 )) { 992 client.patchAdmin("/accounts/cashout") { 993 json { 994 "cashout_payto_uri" to cashout 995 if (name != null) "name" to name 996 } 997 }.assertNoContent() 998 client.getA("/accounts/cashout").assertOkJson<AccountData> { obj -> 999 assertEquals(expect, obj.cashout_payto_uri) 1000 } 1001 } 1002 1003 // Check 2FA 1004 fillTanInfo("merchant") 1005 client.patchA("/accounts/merchant") { 1006 json { "is_public" to false } 1007 }.assertChallenge { 1008 client.getA("/accounts/merchant").assertOkJson<AccountData> { obj -> 1009 assert(obj.is_public) 1010 } 1011 }.assertNoContent() 1012 client.getA("/accounts/merchant").assertOkJson<AccountData> { obj -> 1013 assert(!obj.is_public) 1014 } 1015 } 1016 1017 // Test admin-only account patch 1018 @Test 1019 fun patchRestricted() = bankSetup(conf = "test_restrict.conf") { 1020 // Check restricted 1021 checkAdminOnly( 1022 obj { "name" to "Another Foo" }, 1023 TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME 1024 ) 1025 checkAdminOnly( 1026 obj { "cashout_payto_uri" to IbanPayto.rand() }, 1027 TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT 1028 ) 1029 // Check idempotent 1030 client.getA("/accounts/merchant").assertOkJson<AccountData> { obj -> 1031 client.patchA("/accounts/merchant") { 1032 json { 1033 "name" to obj.name 1034 "cashout_payto_uri" to obj.cashout_payto_uri 1035 "debit_threshold" to obj.debit_threshold 1036 } 1037 }.assertNoContent() 1038 } 1039 } 1040 1041 // Test TAN check account patch 1042 @Test 1043 fun patchTanErr() = bankSetup(conf = "test_tan_err.conf") { 1044 // Check unsupported TAN channel 1045 client.patchA("/accounts/customer") { 1046 json { 1047 "tan_channel" to "email" 1048 } 1049 }.assertConflict(TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED) 1050 } 1051 1052 // PATCH /accounts/USERNAME/auth 1053 @Test 1054 fun passwordChange() = bankSetup { 1055 authRoutine(HttpMethod.Patch, "/accounts/merchant/auth", allowAdmin = true) 1056 1057 // Changing the password. 1058 client.patchA("/accounts/customer/auth") { 1059 json { 1060 "old_password" to "customer-password" 1061 "new_password" to "new-password" 1062 } 1063 }.assertNoContent() 1064 // Previous password should fail. 1065 client.post("/accounts/customer/token") { 1066 basicAuth("customer", "customer-password") 1067 }.assertUnauthorized() 1068 // New password should succeed. 1069 client.post("/accounts/customer/token") { 1070 basicAuth("customer", "new-password") 1071 json { "scope" to "readonly" } 1072 }.assertOk() 1073 client.patchA("/accounts/customer/auth") { 1074 json { 1075 "old_password" to "new-password" 1076 "new_password" to "customer-password" 1077 } 1078 }.assertNoContent() 1079 1080 1081 // Check require test old password 1082 client.patchA("/accounts/customer/auth") { 1083 json { 1084 "old_password" to "bad-password" 1085 "new_password" to "new-password" 1086 } 1087 }.assertConflict(TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD) 1088 1089 // Check require old password for user 1090 client.patchA("/accounts/customer/auth") { 1091 json { 1092 "new_password" to "new-password" 1093 } 1094 }.assertConflict(TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD) 1095 // Testing short password 1096 client.patchA("/accounts/merchant/auth") { 1097 json { 1098 "old_password" to "ignored" 1099 "new_password" to "short" 1100 } 1101 }.assertConflict(TalerErrorCode.BANK_PASSWORD_TOO_SHORT) 1102 // Testing long password 1103 client.patchA("/accounts/merchant/auth") { 1104 json { 1105 "old_password" to "ignored" 1106 "new_password" to "loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong-password" 1107 } 1108 }.assertConflict(TalerErrorCode.BANK_PASSWORD_TOO_LONG) 1109 1110 // Check admin 1111 client.patchAdmin("/accounts/customer/auth") { 1112 json { 1113 "new_password" to "customer-password" 1114 } 1115 }.assertNoContent() 1116 1117 // Check 2FA 1118 fillTanInfo("customer") 1119 client.patchA("/accounts/customer/auth") { 1120 json { 1121 "old_password" to "customer-password" 1122 "new_password" to "it-password" 1123 } 1124 }.assertChallenge().assertNoContent() 1125 client.patchAdmin("/accounts/customer/auth") { 1126 json { 1127 "new_password" to "new-password" 1128 } 1129 }.assertNoContent() 1130 1131 1132 // Check 2FA after password check 1133 client.patchA("/accounts/customer/auth") { 1134 json { 1135 "old_password" to "password" 1136 "new_password" to "new-password" 1137 } 1138 }.assertConflict(TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD) 1139 } 1140 1141 // PATCH /accounts/USERNAME/auth 1142 @Test 1143 fun passwordChangeNoCheck() = bankSetup("test_no_password_check.conf") { 1144 // Testing short password 1145 client.patchA("/accounts/merchant/auth") { 1146 json { 1147 "old_password" to "merchant-password" 1148 "new_password" to "short" 1149 } 1150 }.assertNoContent() 1151 // Testing long password 1152 client.patchA("/accounts/merchant/auth") { 1153 json { 1154 "old_password" to "short" 1155 "new_password" to "looooooooooooooooooooooooooooooooooooooooooooooooooooooooong-password" 1156 } 1157 }.assertNoContent() 1158 } 1159 1160 // GET /public-accounts and GET /accounts 1161 @Test 1162 fun list() = bankSetup(conf = "test_no_conversion.conf") { db -> 1163 authRoutine(HttpMethod.Get, "/accounts", requireAdmin = true) 1164 // Remove default accounts 1165 val defaultAccounts = listOf("merchant", "exchange", "customer") 1166 defaultAccounts.forEach { 1167 client.deleteAdmin("/accounts/$it").assertNoContent() 1168 } 1169 client.getAdmin("/accounts").assertOkJson<ListBankAccountsResponse> { 1170 for (account in it.accounts) { 1171 assertNull(account.conversion_rate) 1172 if (defaultAccounts.contains(account.username)) { 1173 assertEquals(AccountStatus.deleted, account.status) 1174 } else { 1175 assertEquals(AccountStatus.active, account.status) 1176 } 1177 } 1178 } 1179 db.gc.collect(Instant.now(), Duration.ZERO, Duration.ZERO, Duration.ZERO) 1180 // Check error when no public accounts 1181 client.get("/public-accounts").assertNoContent() 1182 client.getAdmin("/accounts").assertOkJson<ListBankAccountsResponse>() 1183 } 1184 1185 @Test 1186 fun listConversionClass() = bankSetup(conf = "test.conf") { db -> 1187 repeat(3) { 1188 createConversionRateClass() 1189 } 1190 1191 // Gen some public and private accounts 1192 repeat(5) { 1193 client.postAdmin("/accounts") { 1194 val mod = it%3 1195 val rateClassId = if (mod in 1..3) mod else null 1196 json { 1197 "username" to "$it" 1198 "password" to "password" 1199 "name" to "Mr 1$it" 1200 "is_public" to (it%2 == 0) 1201 "conversion_rate_class_id" to rateClassId 1202 } 1203 }.assertOk() 1204 } 1205 // All public 1206 client.get("/public-accounts").assertOkJson<PublicAccountsResponse> { 1207 assertEquals(3, it.public_accounts.size) 1208 it.public_accounts.forEach { 1209 assertEquals(0, (it.username.toInt() - 10) % 2) 1210 } 1211 } 1212 // Conversion rate 1213 client.getAdmin("/accounts").assertOkJson<ListBankAccountsResponse> { 1214 for (account in it.accounts) { 1215 val rate = client.getAdmin("/accounts/${account.username}/conversion-info/rate").assertOkJson<ConversionRate>() 1216 assertEquals(account.conversion_rate, rate) 1217 } 1218 } 1219 // Filtering 1220 suspend fun checkIds(query: String, vararg ids: String) { 1221 val res = client.getAdmin("/accounts?$query") 1222 val list = listOf(*ids) 1223 if (list.isEmpty()) { 1224 res.assertNoContent() 1225 } else { 1226 res.assertOkJson<ListBankAccountsResponse> { 1227 assertEquals(list, it.accounts.map { it.username }) 1228 } 1229 } 1230 } 1231 checkIds("", "4", "3", "2", "1", "0", "admin", "customer", "exchange", "merchant") 1232 checkIds("filter_name=1", "4", "3", "2", "1", "0") 1233 checkIds("filter_name=3", "3") 1234 checkIds("conversion_rate_class_id=1", "4", "1") 1235 checkIds("conversion_rate_class_id=2", "2") 1236 checkIds("conversion_rate_class_id=3") 1237 checkIds("conversion_rate_class_id=4") 1238 checkIds("conversion_rate_class_id=0", "3", "0", "admin", "customer", "exchange", "merchant") 1239 checkIds("conversion_rate_class_id=0&filter_name=1", "3", "0") 1240 for ((id, num) in mapOf(1 to 2, 2 to 1, 3 to 0)) { 1241 client.getAdmin("/conversion-rate-classes/$id").assertOkJson<ConversionRateClass> { 1242 assertEquals(it.num_users, num) 1243 } 1244 } 1245 } 1246 1247 // GET /accounts/USERNAME 1248 @Test 1249 fun get() = bankSetup { 1250 authRoutine(HttpMethod.Get, "/accounts/merchant", allowAdmin = true) 1251 // Check ok 1252 client.getA("/accounts/merchant").assertOkJson<AccountData> { 1253 assertEquals("Merchant", it.name) 1254 } 1255 } 1256 } 1257 1258 class CoreBankTransactionsApiTest { 1259 // GET /transactions 1260 @Test 1261 fun history() = bankSetup { 1262 authRoutine(HttpMethod.Get, "/accounts/merchant/transactions", allowAdmin = true) 1263 historyRoutine<BankAccountTransactionsResponse>( 1264 url = "/accounts/customer/transactions", 1265 ids = { it.transactions.map { it.row_id } }, 1266 registered = listOf( 1267 { 1268 // Transactions from merchant to exchange 1269 tx("merchant", "KUDOS:0.1", "customer") 1270 }, 1271 { 1272 // Transactions from exchange to merchant 1273 tx("customer", "KUDOS:0.1", "merchant") 1274 }, 1275 { 1276 // Transactions from merchant to exchange 1277 tx("merchant", "KUDOS:0.1", "customer") 1278 }, 1279 { 1280 // Cashout from merchant 1281 cashout("KUDOS:0.1") 1282 } 1283 ), 1284 ignored = listOf( 1285 { 1286 // Ignore transactions of other accounts 1287 tx("merchant", "KUDOS:0.1", "exchange") 1288 }, 1289 { 1290 // Ignore transactions of other accounts 1291 tx("exchange", "KUDOS:0.1", "merchant") 1292 } 1293 ) 1294 ) 1295 } 1296 1297 // GET /transactions/T_ID 1298 @Test 1299 fun testById() = bankSetup { 1300 authRoutine(HttpMethod.Get, "/accounts/merchant/transactions/42", allowAdmin = true) 1301 1302 // Create transaction 1303 tx("merchant", "KUDOS:0.3", "exchange", "tx") 1304 // Check OK 1305 client.getA("/accounts/merchant/transactions/1") 1306 .assertOkJson<BankAccountTransactionInfo> { tx -> 1307 assertEquals("tx", tx.subject) 1308 assertEquals(TalerAmount("KUDOS:0.3"), tx.amount) 1309 } 1310 // Check unknown transaction 1311 client.getA("/accounts/merchant/transactions/3") 1312 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 1313 // Check another user's transaction 1314 client.getA("/accounts/merchant/transactions/2") 1315 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 1316 } 1317 1318 // POST /transactions 1319 @Test 1320 fun create() = bankSetup { db -> 1321 authRoutine(HttpMethod.Post, "/accounts/merchant/transactions") 1322 1323 val valid_req = obj { 1324 "payto_uri" to "$exchangePayto?message=payout" 1325 "amount" to "KUDOS:0.3" 1326 } 1327 1328 // Check OK 1329 client.postA("/accounts/merchant/transactions") { 1330 json(valid_req) 1331 }.assertOkJson<TransactionCreateResponse> { 1332 client.getA("/accounts/merchant/transactions/${it.row_id}") 1333 .assertOkJson<BankAccountTransactionInfo> { tx -> 1334 assertEquals("payout", tx.subject) 1335 assertEquals(TalerAmount("KUDOS:0.3"), tx.amount) 1336 } 1337 } 1338 1339 // Check idempotency 1340 ShortHashCode.rand().let { requestUid -> 1341 val id = client.postA("/accounts/merchant/transactions") { 1342 json(valid_req) { 1343 "request_uid" to requestUid 1344 } 1345 }.assertOkJson<TransactionCreateResponse>().row_id 1346 client.postA("/accounts/merchant/transactions") { 1347 json(valid_req) { 1348 "request_uid" to requestUid 1349 } 1350 }.assertOkJson<TransactionCreateResponse> { 1351 assertEquals(id, it.row_id) 1352 } 1353 client.postA("/accounts/merchant/transactions") { 1354 json(valid_req) { 1355 "request_uid" to requestUid 1356 "amount" to "KUDOS:42" 1357 } 1358 }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) 1359 } 1360 1361 // Check amount in payto_uri 1362 client.postA("/accounts/merchant/transactions") { 1363 json { 1364 "payto_uri" to "$exchangePayto?message=payout2&amount=KUDOS:1.05" 1365 } 1366 }.assertOkJson <TransactionCreateResponse> { 1367 client.getA("/accounts/merchant/transactions/${it.row_id}") 1368 .assertOkJson<BankAccountTransactionInfo> { tx -> 1369 assertEquals("payout2", tx.subject) 1370 assertEquals(TalerAmount("KUDOS:1.05"), tx.amount) 1371 } 1372 } 1373 1374 // Check amount in payto_uri precedence 1375 client.postA("/accounts/merchant/transactions") { 1376 json { 1377 "payto_uri" to "$exchangePayto?message=payout3&amount=KUDOS:1.05" 1378 "amount" to "KUDOS:10.003" 1379 } 1380 }.assertOkJson<TransactionCreateResponse> { 1381 client.getA("/accounts/merchant/transactions/${it.row_id}") 1382 .assertOkJson<BankAccountTransactionInfo> { tx -> 1383 assertEquals("payout3", tx.subject) 1384 assertEquals(TalerAmount("KUDOS:1.05"), tx.amount) 1385 } 1386 } 1387 // Testing the wrong currency 1388 client.postA("/accounts/merchant/transactions") { 1389 json(valid_req) { 1390 "amount" to "EUR:3.3" 1391 } 1392 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 1393 // Surpassing the debt limit 1394 client.postA("/accounts/merchant/transactions") { 1395 json(valid_req) { 1396 "amount" to "KUDOS:555" 1397 } 1398 }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) 1399 // Missing message 1400 client.postA("/accounts/merchant/transactions") { 1401 json(valid_req) { 1402 "payto_uri" to "$exchangePayto" 1403 } 1404 }.assertBadRequest() 1405 // Unknown creditor 1406 client.postA("/accounts/merchant/transactions") { 1407 json(valid_req) { 1408 "payto_uri" to "$unknownPayto?message=payout" 1409 } 1410 }.assertConflict(TalerErrorCode.BANK_UNKNOWN_CREDITOR) 1411 // Transaction to self 1412 client.postA("/accounts/merchant/transactions") { 1413 json(valid_req) { 1414 "payto_uri" to "$merchantPayto?message=payout" 1415 } 1416 }.assertConflict(TalerErrorCode.BANK_SAME_ACCOUNT) 1417 // Transaction to admin 1418 val adminPayto = client.getA("/accounts/admin") 1419 .assertOkJson<AccountData>().payto_uri 1420 client.postA("/accounts/merchant/transactions") { 1421 json(valid_req) { 1422 "payto_uri" to "$adminPayto&message=payout" 1423 } 1424 }.assertConflict(TalerErrorCode.BANK_ADMIN_CREDITOR) 1425 1426 // Init state 1427 assertBalance("merchant", "+KUDOS:0") 1428 assertBalance("customer", "+KUDOS:0") 1429 // Send 2 times 3 1430 repeat(2) { 1431 tx("merchant", "KUDOS:3", "customer") 1432 } 1433 client.postA("/accounts/merchant/transactions") { 1434 json { 1435 "payto_uri" to "$customerPayto?message=payout2&amount=KUDOS:5" 1436 } 1437 }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) 1438 assertBalance("merchant", "-KUDOS:6") 1439 assertBalance("customer", "+KUDOS:6") 1440 // Send through debt 1441 tx("customer", "KUDOS:10", "merchant") 1442 assertBalance("merchant", "+KUDOS:4") 1443 assertBalance("customer", "-KUDOS:4") 1444 tx("merchant", "KUDOS:4", "customer") 1445 1446 // Check bounce 1447 assertBalance("merchant", "+KUDOS:0") 1448 assertBalance("exchange", "+KUDOS:0") 1449 tx("merchant", "KUDOS:1", "exchange", "") // Bounce common to transaction 1450 tx("merchant", "KUDOS:1", "exchange", "Malformed") // Bounce malformed transaction 1451 tx("merchant", "KUDOS:1", "exchange", "ADMIN BALANCE ADJUST") // Bounce admin balance adjust 1452 val reservePub = EddsaPublicKey.randEdsaKey() 1453 tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reservePub)) // Accept incoming 1454 tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reservePub)) // Bounce reserve_pub reuse 1455 assertBalance("merchant", "-KUDOS:1") 1456 assertBalance("exchange", "+KUDOS:1") 1457 1458 // Check warn 1459 assertBalance("merchant", "-KUDOS:1") 1460 assertBalance("exchange", "+KUDOS:1") 1461 tx("exchange", "KUDOS:1", "merchant", "") // Warn common to transaction 1462 tx("exchange", "KUDOS:1", "merchant", "Malformed") // Warn malformed transaction 1463 val wtid = ShortHashCode.rand() 1464 val exchange = BaseURL.parse("http://exchange.example.com/") 1465 tx("exchange", "KUDOS:1", "merchant", randOutgoingSubject(wtid, exchange)) // Accept outgoing 1466 tx("exchange", "KUDOS:1", "merchant", randOutgoingSubject(wtid, exchange)) // Warn wtid reuse 1467 assertBalance("merchant", "+KUDOS:3") 1468 assertBalance("exchange", "-KUDOS:3") 1469 1470 // Check 2fa 1471 fillTanInfo("merchant") 1472 assertBalance("merchant", "+KUDOS:3") 1473 assertBalance("customer", "+KUDOS:0") 1474 client.postA("/accounts/merchant/transactions") { 1475 json { 1476 "payto_uri" to "$customerPayto?message=tan+check&amount=KUDOS:1" 1477 } 1478 }.assertChallenge { 1479 assertBalance("merchant", "+KUDOS:3") 1480 assertBalance("customer", "+KUDOS:0") 1481 }.assertOkJson <TransactionCreateResponse> { 1482 assertBalance("merchant", "+KUDOS:2") 1483 assertBalance("customer", "+KUDOS:1") 1484 } 1485 1486 // Check 2fa idempotency 1487 val req = obj { 1488 "payto_uri" to "$customerPayto?message=tan+check&amount=KUDOS:1" 1489 "request_uid" to ShortHashCode.rand() 1490 } 1491 val id = client.postA("/accounts/merchant/transactions") { 1492 json(req) 1493 }.assertChallenge { 1494 assertBalance("merchant", "+KUDOS:2") 1495 assertBalance("customer", "+KUDOS:1") 1496 }.assertOkJson <TransactionCreateResponse> { 1497 assertBalance("merchant", "+KUDOS:1") 1498 assertBalance("customer", "+KUDOS:2") 1499 }.row_id 1500 client.postA("/accounts/merchant/transactions") { 1501 json(req) 1502 }.assertOkJson<TransactionCreateResponse> { 1503 assertEquals(id, it.row_id) 1504 } 1505 client.postA("/accounts/merchant/transactions") { 1506 json(req) { 1507 "payto_uri" to "$customerPayto?message=tan+chec2k&amount=KUDOS:1" 1508 } 1509 }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) 1510 } 1511 1512 @Test 1513 fun createWithFee() = bankSetup(conf = "test_with_fees.conf") { 1514 // Init state 1515 assertBalance("merchant", "+KUDOS:0") 1516 assertBalance("customer", "+KUDOS:0") 1517 assertBalance("admin", "+KUDOS:0") 1518 1519 // Check fee are sent to admin 1520 tx("merchant", "KUDOS:3", "customer") 1521 assertBalance("merchant", "-KUDOS:3.1") 1522 assertBalance("customer", "+KUDOS:3") 1523 assertBalance("admin", "+KUDOS:0.1") 1524 1525 // Check amount with fee and min & max are checked 1526 for (amount in listOf("KUDOS:7", "KUDOS:6.9", "KUDOS:0", "KUDOS:150")) { 1527 client.postA("/accounts/merchant/transactions") { 1528 json { 1529 "payto_uri" to "$customerPayto?message=payout2&amount=$amount" 1530 } 1531 }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) 1532 } 1533 // Check empty account 1534 tx("merchant", "KUDOS:6.8", "customer") 1535 assertBalance("merchant", "-KUDOS:10") 1536 assertBalance("customer", "+KUDOS:9.8") 1537 assertBalance("admin", "+KUDOS:0.2") 1538 1539 // Admin check no fee 1540 tx("admin", "KUDOS:0.35", "merchant") 1541 assertBalance("merchant", "-KUDOS:9.65") 1542 assertBalance("admin", "-KUDOS:0.15") 1543 1544 // Admin recover from debt 1545 tx("customer", "KUDOS:1", "merchant") 1546 assertBalance("admin", "-KUDOS:0.05") 1547 tx("customer", "KUDOS:1", "merchant") 1548 assertBalance("merchant", "-KUDOS:7.65") 1549 assertBalance("customer", "+KUDOS:7.6") 1550 assertBalance("admin", "+KUDOS:0.05") 1551 } 1552 } 1553 1554 class CoreBankWithdrawalApiTest { 1555 // POST /accounts/USERNAME/withdrawals 1556 @Test 1557 fun create() = bankSetup { 1558 authRoutine(HttpMethod.Post, "/accounts/merchant/withdrawals") 1559 1560 // Check OK 1561 for (valid in listOf( 1562 obj {}, 1563 obj { "amount" to "KUDOS:1.0" }, 1564 obj { "suggested_amount" to "KUDOS:2.0" }, 1565 obj { 1566 "amount" to "KUDOS:3.0" 1567 "suggested_amount" to "KUDOS:4.0" 1568 } 1569 )) { 1570 // Check OK 1571 client.postA("/accounts/merchant/withdrawals") { 1572 json(valid) 1573 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1574 assertEquals("taler+http://withdraw/localhost:8080/taler-integration/${it.withdrawal_id}", it.taler_withdraw_uri) 1575 } 1576 } 1577 1578 // Check exchange account 1579 client.postA("/accounts/exchange/withdrawals") { 1580 json { "amount" to "KUDOS:9.0" } 1581 }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE) 1582 1583 // Check insufficient fund 1584 client.postA("/accounts/merchant/withdrawals") { 1585 json { "amount" to "KUDOS:90" } 1586 }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) 1587 client.postA("/accounts/merchant/withdrawals") { 1588 json { "suggested_amount" to "KUDOS:90" } 1589 }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) 1590 1591 // Check wrong currency 1592 client.postA("/accounts/merchant/withdrawals") { 1593 json { "amount" to "EUR:90" } 1594 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 1595 client.postA("/accounts/merchant/withdrawals") { 1596 json { "suggested_amount" to "EUR:90" } 1597 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 1598 } 1599 1600 @Test 1601 fun createWithFee() = bankSetup(conf = "test_with_fees.conf") { 1602 // Check insufficient fund 1603 for (amount in listOf("KUDOS:11", "KUDOS:10", "KUDOS:0", "KUDOS:150")) { 1604 for (name in listOf("amount", "suggested_amount")) { 1605 client.postA("/accounts/merchant/withdrawals") { 1606 json { name to amount } 1607 }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) 1608 } 1609 } 1610 1611 // Check OK 1612 for (name in listOf("amount", "suggested_amount")) { 1613 client.postA("/accounts/merchant/withdrawals") { 1614 json { name to "KUDOS:9.9" } 1615 }.assertOk() 1616 } 1617 } 1618 1619 // GET /withdrawals/withdrawal_id 1620 @Test 1621 fun get() = bankSetup { 1622 // Check OK 1623 for (valid in listOf( 1624 Pair(null, null), 1625 Pair("KUDOS:1.0", null), 1626 Pair(null, "KUDOS:2.0") , 1627 Pair("KUDOS:3.0", "KUDOS:4.0") 1628 )) { 1629 val amount = valid.first?.run(::TalerAmount) 1630 val suggested = valid.second?.run(::TalerAmount) 1631 client.postA("/accounts/merchant/withdrawals") { 1632 json { 1633 "amount" to amount 1634 "suggested_amount" to suggested 1635 } 1636 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1637 client.get("/withdrawals/${it.withdrawal_id}") 1638 .assertOkJson<WithdrawalPublicInfo> { 1639 assertEquals(amount, it.amount) 1640 assertEquals(suggested, it.suggested_amount) 1641 } 1642 } 1643 } 1644 1645 // Check polling 1646 statusRoutine<WithdrawalPublicInfo>("/withdrawals") { it.status } 1647 1648 // Check bad UUID 1649 client.get("/withdrawals/chocolate").assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) 1650 1651 // Check unknown 1652 client.get("/withdrawals/${UUID.randomUUID()}") 1653 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 1654 } 1655 1656 // POST /accounts/USERNAME/withdrawals/withdrawal_id/abort 1657 @Test 1658 fun abort() = bankSetup { 1659 authRoutine(HttpMethod.Post, "/accounts/merchant/withdrawals/42/abort") 1660 1661 // Check abort created 1662 client.postA("/accounts/merchant/withdrawals") { 1663 json { "amount" to "KUDOS:1" } 1664 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1665 val uuid = it.withdrawal_id 1666 1667 // Check OK 1668 client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() 1669 // Check idempotence 1670 client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() 1671 } 1672 1673 // Check abort selected 1674 client.postA("/accounts/merchant/withdrawals") { 1675 json { "amount" to "KUDOS:1" } 1676 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1677 val uuid = it.withdrawal_id 1678 withdrawalSelect(uuid) 1679 1680 // Check OK 1681 client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() 1682 // Check idempotence 1683 client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() 1684 } 1685 1686 // Check abort confirmed 1687 client.postA("/accounts/merchant/withdrawals") { 1688 json { "amount" to "KUDOS:1" } 1689 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1690 val uuid = it.withdrawal_id 1691 withdrawalSelect(uuid) 1692 client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent() 1693 1694 // Check error 1695 client.postA("/accounts/merchant/withdrawals/$uuid/abort") 1696 .assertConflict(TalerErrorCode.BANK_ABORT_CONFIRM_CONFLICT) 1697 } 1698 1699 // Check confirm another user's operation 1700 client.postA("/accounts/customer/withdrawals") { 1701 json { "amount" to "KUDOS:1" } 1702 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1703 val uuid = it.withdrawal_id 1704 withdrawalSelect(uuid) 1705 1706 // Check error 1707 client.postA("/accounts/merchant/withdrawals/$uuid/abort") 1708 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 1709 } 1710 1711 // Check bad UUID 1712 client.postA("/accounts/merchant/withdrawals/chocolate/abort") 1713 .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) 1714 1715 // Check unknown 1716 client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/abort") 1717 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 1718 } 1719 1720 // POST /accounts/USERNAME/withdrawals/withdrawal_id/confirm 1721 @Test 1722 fun confirm() = bankSetup { 1723 authRoutine(HttpMethod.Post, "/accounts/merchant/withdrawals/42/confirm") 1724 // Check confirm created 1725 client.postA("/accounts/merchant/withdrawals") { 1726 json { "amount" to "KUDOS:1" } 1727 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1728 val uuid = it.withdrawal_id 1729 1730 // Check err 1731 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") 1732 .assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE) 1733 } 1734 1735 // Check confirm selected 1736 client.postA("/accounts/merchant/withdrawals") { 1737 json { "amount" to "KUDOS:1" } 1738 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1739 val uuid = it.withdrawal_id 1740 withdrawalSelect(uuid) 1741 1742 // Check amount differs 1743 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { 1744 json { "amount" to "KUDOS:2" } 1745 }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS) 1746 1747 // Check OK 1748 client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent() 1749 // Check idempotence 1750 client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent() 1751 1752 // Check amount differs 1753 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { 1754 json { "amount" to "KUDOS:2" } 1755 }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS) 1756 } 1757 1758 // Check confirm with amount 1759 client.postA("/accounts/merchant/withdrawals") { 1760 json {} 1761 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1762 val uuid = it.withdrawal_id 1763 withdrawalSelect(uuid) 1764 1765 // Check missing amount 1766 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") 1767 .assertConflict(TalerErrorCode.BANK_AMOUNT_REQUIRED) 1768 1769 // Check OK 1770 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { 1771 json { "amount" to "KUDOS:1" } 1772 }.assertNoContent() 1773 // Check idempotence 1774 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { 1775 json { "amount" to "KUDOS:1" } 1776 }.assertNoContent() 1777 1778 // Check amount differs 1779 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { 1780 json { "amount" to "KUDOS:2" } 1781 }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS) 1782 } 1783 1784 // Check confirm aborted 1785 client.postA("/accounts/merchant/withdrawals") { 1786 json { "amount" to "KUDOS:1" } 1787 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1788 val uuid = it.withdrawal_id 1789 withdrawalSelect(uuid) 1790 client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() 1791 1792 // Check error 1793 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") 1794 .assertConflict(TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT) 1795 } 1796 1797 // Check balance insufficient 1798 client.postA("/accounts/merchant/withdrawals") { 1799 json { "amount" to "KUDOS:5" } 1800 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1801 val uuid = it.withdrawal_id 1802 withdrawalSelect(uuid) 1803 1804 // Send too much money 1805 tx("merchant", "KUDOS:5", "customer") 1806 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") 1807 .assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) 1808 1809 // Check can abort because not confirmed 1810 client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() 1811 } 1812 1813 // Check confirm another user's operation 1814 client.postA("/accounts/customer/withdrawals") { 1815 json { "amount" to "KUDOS:1" } 1816 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1817 val uuid = it.withdrawal_id 1818 withdrawalSelect(uuid) 1819 1820 // Check error 1821 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") 1822 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 1823 } 1824 1825 // Check bad UUID 1826 client.postA("/accounts/merchant/withdrawals/chocolate/confirm") 1827 .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) 1828 1829 // Check unknown 1830 client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/confirm") 1831 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 1832 1833 // Check 2fa without body 1834 fillTanInfo("merchant") 1835 assertBalance("merchant", "-KUDOS:7") 1836 client.postA("/accounts/merchant/withdrawals") { 1837 json { "amount" to "KUDOS:1" } 1838 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1839 val uuid = it.withdrawal_id 1840 withdrawalSelect(uuid) 1841 1842 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") 1843 .assertChallenge { 1844 assertBalance("merchant", "-KUDOS:7") 1845 }.assertNoContent() 1846 } 1847 1848 // Check 2fa with body 1849 fillTanInfo("merchant") 1850 assertBalance("merchant", "-KUDOS:8") 1851 client.postA("/accounts/merchant/withdrawals") { 1852 json {} 1853 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1854 val uuid = it.withdrawal_id 1855 withdrawalSelect(uuid) 1856 1857 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { 1858 json { "amount" to "KUDOS:1" } 1859 } 1860 .assertChallenge { 1861 assertBalance("merchant", "-KUDOS:8") 1862 }.assertNoContent() 1863 } 1864 assertBalance("merchant", "-KUDOS:9") 1865 } 1866 1867 @Test 1868 fun confirmWithFee() = bankSetup(conf = "test_with_fees.conf") { db -> 1869 suspend fun run(amount: TalerAmount): HttpResponse { 1870 val uuid = UUID.randomUUID() 1871 // Create a selected withdrawal directly in the database to bypass checks 1872 db.serializable(""" 1873 INSERT INTO taler_withdrawal_operations(withdrawal_uuid,amount,selected_exchange_payto,selection_done,wallet_bank_account,creation_date) 1874 VALUES (?, (?, ?)::taler_amount, ?, true, 3, 0) 1875 """) { 1876 bind(uuid) 1877 bind(amount) 1878 bind(exchangePayto.canonical) 1879 executeUpdate() 1880 } 1881 1882 return client.postA("/accounts/customer/withdrawals/$uuid/confirm") 1883 } 1884 1885 // Check insufficient fund 1886 for (amount in listOf("KUDOS:11", "KUDOS:10", "KUDOS:0", "KUDOS:150")) { 1887 run(TalerAmount(amount)).assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) 1888 } 1889 1890 // Check OK 1891 run(TalerAmount("KUDOS:9.9")) 1892 } 1893 } 1894 1895 class CoreBankCashoutApiTest { 1896 // POST /accounts/{USERNAME}/cashouts 1897 @Test 1898 fun create() = bankSetup { 1899 authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts") 1900 1901 val req = obj { 1902 "request_uid" to ShortHashCode.rand() 1903 "amount_debit" to "KUDOS:1" 1904 "amount_credit" to convert("KUDOS:1") 1905 } 1906 1907 // Missing info 1908 client.postA("/accounts/customer/cashouts") { 1909 json(req) 1910 }.assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE) 1911 1912 fillCashoutInfo("customer") 1913 1914 // Check OK 1915 val id = client.postA("/accounts/customer/cashouts") { 1916 json(req) 1917 }.assertOkJson<CashoutResponse>().cashout_id 1918 1919 // Check idempotent 1920 client.postA("/accounts/customer/cashouts") { 1921 json(req) 1922 }.assertOkJson<CashoutResponse> { 1923 assertEquals(id, it.cashout_id) 1924 } 1925 1926 // Trigger conflict due to reused request_uid 1927 client.postA("/accounts/customer/cashouts") { 1928 json(req) { 1929 "amount_debit" to "KUDOS:2" 1930 "amount_credit" to convert("KUDOS:2") 1931 } 1932 }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) 1933 1934 // Check exchange account 1935 client.postA("/accounts/exchange/cashouts") { 1936 json(req) 1937 }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE) 1938 1939 // Check insufficient fund 1940 client.postA("/accounts/customer/cashouts") { 1941 json(req) { 1942 "request_uid" to ShortHashCode.rand() 1943 "amount_debit" to "KUDOS:75" 1944 "amount_credit" to convert("KUDOS:75") 1945 } 1946 }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) 1947 1948 // Check wrong conversion 1949 client.postA("/accounts/customer/cashouts") { 1950 json(req) { 1951 "amount_credit" to convert("KUDOS:2") 1952 } 1953 }.assertConflict(TalerErrorCode.BANK_BAD_CONVERSION) 1954 1955 // Check min amount 1956 client.postA("/accounts/customer/cashouts") { 1957 json(req) { 1958 "amount_debit" to "KUDOS:0.09" 1959 } 1960 }.assertConflict(TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL) 1961 1962 // Check custom min account 1963 createConversionRateClass(cashout_min_amount = TalerAmount("KUDOS:10")) 1964 client.patchAdmin("/accounts/customer") { 1965 json { 1966 "conversion_rate_class_id" to 1 1967 } 1968 }.assertNoContent() 1969 client.postA("/accounts/customer/cashouts") { 1970 json(req) { 1971 "amount_debit" to "KUDOS:5" 1972 "amount_credit" to convert("KUDOS:5") 1973 } 1974 }.assertConflict(TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL) 1975 client.patchAdmin("/accounts/customer") { 1976 json { 1977 "conversion_rate_class_id" to (null as Long?) 1978 } 1979 }.assertNoContent() 1980 1981 // Check wrong currency 1982 client.postA("/accounts/customer/cashouts") { 1983 json(req) { 1984 "amount_debit" to "EUR:1" 1985 } 1986 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 1987 client.postA("/accounts/customer/cashouts") { 1988 json(req) { 1989 "amount_credit" to "KUDOS:1" 1990 } 1991 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 1992 1993 // Check 2fa 1994 fillTanInfo("customer") 1995 assertBalance("customer", "-KUDOS:1") 1996 client.postA("/accounts/customer/cashouts") { 1997 json(req) { 1998 "request_uid" to ShortHashCode.rand() 1999 } 2000 }.assertChallenge { 2001 assertBalance("customer", "-KUDOS:1") 2002 }.assertOkJson<CashoutResponse> { 2003 assertBalance("customer", "-KUDOS:2") 2004 } 2005 } 2006 2007 // GET /accounts/{USERNAME}/cashouts/{CASHOUT_ID} 2008 @Test 2009 fun get() = bankSetup { 2010 authRoutine(HttpMethod.Get, "/accounts/merchant/cashouts/42", allowAdmin = true) 2011 fillCashoutInfo("customer") 2012 2013 val amountDebit = TalerAmount("KUDOS:1.5") 2014 val amountCredit = convert("KUDOS:1.5") 2015 val req = obj { 2016 "amount_debit" to amountDebit 2017 "amount_credit" to amountCredit 2018 } 2019 2020 // Check confirm 2021 client.postA("/accounts/customer/cashouts") { 2022 json(req) { "request_uid" to ShortHashCode.rand() } 2023 }.assertOkJson<CashoutResponse> { 2024 val id = it.cashout_id 2025 client.getA("/accounts/customer/cashouts/$id") 2026 .assertOkJson<CashoutStatusResponse> { 2027 assertEquals(amountDebit, it.amount_debit) 2028 assertEquals(amountCredit, it.amount_credit) 2029 } 2030 } 2031 2032 // Check bad UUID 2033 client.getA("/accounts/customer/cashouts/chocolate") 2034 .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) 2035 2036 // Check unknown 2037 client.getA("/accounts/customer/cashouts/42") 2038 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 2039 2040 // Check get another user's operation 2041 client.postA("/accounts/customer/cashouts") { 2042 json(req) { "request_uid" to ShortHashCode.rand() } 2043 }.assertOkJson<CashoutResponse> { 2044 val id = it.cashout_id 2045 2046 // Check error 2047 client.getA("/accounts/merchant/cashouts/$id") 2048 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 2049 } 2050 } 2051 2052 // GET /accounts/{USERNAME}/cashouts 2053 @Test 2054 fun history() = bankSetup { 2055 authRoutine(HttpMethod.Get, "/accounts/merchant/cashouts", allowAdmin = true) 2056 historyRoutine<Cashouts>( 2057 url = "/accounts/customer/cashouts", 2058 ids = { it.cashouts.map { it.cashout_id } }, 2059 registered = listOf { cashout("KUDOS:0.1") }, 2060 polling = false 2061 ) 2062 } 2063 2064 // GET /cashouts 2065 @Test 2066 fun globalHistory() = bankSetup { 2067 authRoutine(HttpMethod.Get, "/cashouts", requireAdmin = true) 2068 historyRoutine<GlobalCashouts>( 2069 url = "/cashouts", 2070 ids = { it.cashouts.map { it.cashout_id } }, 2071 registered = listOf { cashout("KUDOS:0.1") }, 2072 polling = false, 2073 auth = "admin" 2074 ) 2075 } 2076 2077 @Test 2078 fun notImplemented() = bankSetup("test_no_conversion.conf") { 2079 client.get("/accounts/customer/cashouts") 2080 .assertNotImplemented() 2081 } 2082 } 2083 2084 class CoreBankTanApiTest { 2085 // POST /accounts/{USERNAME}/challenge/{challenge_id} 2086 @Test 2087 fun send() = bankSetup { 2088 suspend fun HttpResponse.expectMfa(vararg tans: Pair<TanChannel, String>): HttpResponse { 2089 return assertChallenge { res -> 2090 assertEquals(setOf(*tans), res.challenges.map { it.tan_channel to it.tan_info }.toSet()) 2091 assertFalse(res.combi_and) 2092 } 2093 } 2094 suspend fun HttpResponse.expectValidation(vararg tans: Pair<TanChannel, String>): HttpResponse { 2095 return assertChallenge { res -> 2096 assertEquals(setOf(*tans), res.challenges.map { it.tan_channel to it.tan_info }.toSet()) 2097 assertTrue(res.combi_and) 2098 } 2099 } 2100 2101 // Set up 2fa 2102 client.patchA("/accounts/merchant") { 2103 json { 2104 "contact_data" to obj { 2105 "phone" to "+99" 2106 "email" to "email@example.com" 2107 } 2108 "tan_channel" to "sms" 2109 } 2110 }.expectValidation(TanChannel.sms to "+99") 2111 .assertNoContent() 2112 2113 // Update 2fa settings - first 2FA challenge then new tan channel check 2114 client.patchA("/accounts/merchant") { 2115 json { // Info change 2116 "contact_data" to obj { "phone" to "+98" } 2117 } 2118 }.expectValidation(TanChannel.sms to "+99", TanChannel.sms to "+98") 2119 .assertNoContent() 2120 client.patchA("/accounts/merchant") { 2121 json { // Channel change 2122 "tan_channel" to "email" 2123 } 2124 }.expectValidation(TanChannel.sms to "+98", TanChannel.email to "email@example.com") 2125 .assertNoContent() 2126 client.patchA("/accounts/merchant") { 2127 json { // Both change 2128 "contact_data" to obj { "phone" to "+97" } 2129 "tan_channel" to "sms" 2130 } 2131 }.expectValidation(TanChannel.email to "email@example.com", TanChannel.sms to "+97") 2132 .assertNoContent() 2133 2134 // Disable 2fa 2135 client.patchA("/accounts/merchant") { 2136 json { "tan_channel" to null as String? } 2137 }.expectValidation(TanChannel.sms to "+97") 2138 .assertNoContent() 2139 2140 // Update mfa settings - first mfa challenge then new tan channel check 2141 client.patchA("/accounts/merchant") { 2142 json { // All channels 2143 "tan_channels" to setOf("sms", "email") 2144 } 2145 }.expectValidation(TanChannel.sms to "+97", TanChannel.email to "email@example.com") 2146 .assertNoContent() 2147 client.patchA("/accounts/merchant") { 2148 json { // All info changes 2149 "contact_data" to obj { 2150 "phone" to "+99" 2151 "email" to "email2@example.com" 2152 } 2153 } 2154 }.expectMfa(TanChannel.sms to "+97", TanChannel.email to "email@example.com") 2155 .expectValidation(TanChannel.sms to "+99", TanChannel.email to "email2@example.com") 2156 .assertNoContent() 2157 2158 // Disable mfa 2159 client.patchA("/accounts/merchant") { 2160 json { "tan_channels" to emptySet<String>() } 2161 }.expectMfa(TanChannel.sms to "+99", TanChannel.email to "email2@example.com") 2162 .assertNoContent() 2163 2164 2165 // Admin has no 2FA 2166 client.patchAdmin("/accounts/merchant") { 2167 json { 2168 "contact_data" to obj { "phone" to "+99" } 2169 "tan_channel" to "sms" 2170 } 2171 }.assertNoContent() 2172 client.patchAdmin("/accounts/merchant") { 2173 json { "tan_channel" to "email" } 2174 }.assertNoContent() 2175 client.patchAdmin("/accounts/merchant") { 2176 json { "tan_channel" to null as String? } 2177 }.assertNoContent() 2178 2179 // Check retry and invalidate 2180 client.patchA("/accounts/merchant") { 2181 json { 2182 "contact_data" to obj { "phone" to "+88" } 2183 "tan_channel" to "sms" 2184 } 2185 }.assertChallenge().assertNoContent() 2186 client.patchA("/accounts/merchant") { 2187 json { "is_public" to false } 2188 }.assertAcceptedJson<ChallengeResponse> { 2189 val challenge = it.challenges[0] 2190 // Check ok 2191 client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") 2192 .assertOk() 2193 val code = tanCode("+88") 2194 assertNotNull(code) 2195 // Check retry 2196 client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") 2197 .assertOk() 2198 assertNull(tanCode("+88")) 2199 // Idempotent patch does nothing 2200 client.patchA("/accounts/merchant") { 2201 json { 2202 "contact_data" to obj { "phone" to "+88" } 2203 "tan_channel" to "sms" 2204 } 2205 } 2206 client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") 2207 .assertOk() 2208 assertNull(tanCode("+88")) 2209 2210 // Change 2fa settings 2211 client.patchA("/accounts/merchant") { 2212 json { 2213 "tan_channel" to "email" 2214 } 2215 }.expectValidation(TanChannel.sms to "+88", TanChannel.email to "email2@example.com") 2216 .assertNoContent() 2217 2218 // Check invalidated 2219 client.postA("/accounts/merchant/challenge/${challenge.challenge_id}/confirm") { 2220 json { "tan" to code } 2221 }.assertNotFound(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED) 2222 client.patchA("/accounts/merchant") { 2223 headers[TALER_CHALLENGE_IDS] = "${challenge.challenge_id}" 2224 json { "is_public" to false } 2225 }.expectValidation(TanChannel.email to "email2@example.com") 2226 .assertNoContent() 2227 } 2228 2229 // Unknown challenge 2230 client.postA("/accounts/merchant/challenge/${UUID.randomUUID()}") 2231 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 2232 } 2233 2234 @Test 2235 fun sendRateLimited() = bankSetup { 2236 fillTanInfo("merchant") 2237 2238 suspend fun ApplicationTestBuilder.txChallenge() 2239 = client.postA("/accounts/merchant/transactions") { 2240 json { 2241 "payto_uri" to "$customerPayto?message=tx&amount=KUDOS:0.1" 2242 } 2243 }.assertAcceptedJson<ChallengeResponse>().challenges[0] 2244 suspend fun ApplicationTestBuilder.submit(challenge: Challenge) 2245 = client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") 2246 .assertOkJson<ChallengeRequestResponse>() 2247 2248 2249 // Start a legitimate challenge and submit it 2250 val oldChallenge = txChallenge() 2251 submit(oldChallenge) 2252 val tanCode = tanCode(oldChallenge.tan_info) 2253 2254 // Challenge creation is not rate limited 2255 repeat(MAX_ACTIVE_CHALLENGES*2) { 2256 txChallenge() 2257 } 2258 2259 // Challenge submission is rate limited 2260 repeat(MAX_ACTIVE_CHALLENGES-1) { 2261 submit(txChallenge()) 2262 } 2263 val challenge = txChallenge() 2264 client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") 2265 .assertTooManyRequests(TalerErrorCode.BANK_TAN_RATE_LIMITED) 2266 2267 // Old already submitted challenge still works 2268 val transmission = submit(oldChallenge) 2269 client.postA("/accounts/merchant/challenge/${oldChallenge.challenge_id}/confirm") { 2270 json { "tan" to tanCode } 2271 }.assertNoContent() 2272 2273 // Now an active challenge slot have been freed 2274 submit(challenge) 2275 2276 // We are rate limited again 2277 val newChallenge = txChallenge() 2278 client.postA("/accounts/merchant/challenge/${newChallenge.challenge_id}") 2279 .assertTooManyRequests(TalerErrorCode.BANK_TAN_RATE_LIMITED) 2280 } 2281 2282 // POST /accounts/{USERNAME}/challenge/{challenge_id} 2283 @Test 2284 fun sendTanErr() = bankSetup("test_tan_err.conf") { 2285 // Check fail 2286 fillTanInfo("merchant") 2287 client.patchA("/accounts/merchant") { 2288 json { "is_public" to false } 2289 }.assertAcceptedJson<ChallengeResponse> { 2290 val challenge = it.challenges[0] 2291 client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") 2292 .assertStatus(HttpStatusCode.BadGateway, TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED) 2293 } 2294 } 2295 2296 // POST /accounts/{USERNAME}/challenge/{challenge_id}/confirm 2297 @Test 2298 fun confirm() = bankSetup { 2299 fillTanInfo("merchant") 2300 2301 // Check simple case 2302 client.patchA("/accounts/merchant") { 2303 json { "is_public" to false } 2304 }.assertAcceptedJson<ChallengeResponse> { 2305 val challenge = it.challenges[0] 2306 val id = challenge.challenge_id 2307 client.postA("/accounts/merchant/challenge/$id") 2308 .assertOkJson<ChallengeRequestResponse>() 2309 val code = tanCode(challenge.tan_info) 2310 2311 // Check bad TAN code 2312 client.postA("/accounts/merchant/challenge/$id/confirm") { 2313 json { "tan" to "nice-try" } 2314 }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED) 2315 2316 // Check wrong account 2317 client.postA("/accounts/customer/challenge/$id/confirm") { 2318 json { "tan" to "nice-try" } 2319 }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED) 2320 2321 // Check OK 2322 client.postA("/accounts/merchant/challenge/$id/confirm") { 2323 json { "tan" to code } 2324 }.assertNoContent() 2325 // Check idempotence 2326 client.postA("/accounts/merchant/challenge/$id/confirm") { 2327 json { "tan" to code } 2328 }.assertNoContent() 2329 2330 // Unknown challenge 2331 client.postA("/accounts/merchant/challenge/${UUID.randomUUID()}/confirm") { 2332 json { "tan" to code } 2333 }.assertNotFound(TalerErrorCode.BANK_CHALLENGE_NOT_FOUND) 2334 } 2335 2336 // Check invalidation 2337 client.patchA("/accounts/merchant") { 2338 json { "is_public" to true } 2339 }.assertAcceptedJson<ChallengeResponse> { 2340 val challenge = it.challenges[0] 2341 val id = challenge.challenge_id 2342 client.postA("/accounts/merchant/challenge/$id") 2343 .assertOkJson<ChallengeRequestResponse>() 2344 2345 // Check invalidated 2346 fillTanInfo("merchant") 2347 client.postA("/accounts/merchant/challenge/$id/confirm") { 2348 json { "tan" to tanCode(challenge.tan_info) } 2349 }.assertNotFound(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED) 2350 2351 client.postA("/accounts/merchant/challenge/$id") 2352 .assertNotFound(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED) 2353 } 2354 } 2355 } 2356 2357 class CoreBankConversionApiTest { 2358 // POST /conversion-rate-classes 2359 // GET /conversion-rate-classes 2360 // GET /conversion-rate-classes/{CLASS_ID} 2361 @Test 2362 fun classes() = bankSetup() { 2363 authRoutine(HttpMethod.Post, "/conversion-rate-classes", requireAdmin = true) 2364 authRoutine(HttpMethod.Get, "/conversion-rate-classes", requireAdmin = true) 2365 authRoutine(HttpMethod.Get, "/conversion-rate-classes/1", requireAdmin = true) 2366 2367 val fullInput = obj { 2368 "description" to "A nice little class" 2369 "cashin_ratio" to "0.1" 2370 "cashin_fee" to "KUDOS:0.2" 2371 "cashin_tiny_amount" to "KUDOS:0.3" 2372 "cashin_rounding_mode" to "nearest" 2373 "cashin_min_amount" to "EUR:0" 2374 "cashout_ratio" to "0.4" 2375 "cashout_fee" to "EUR:0.5" 2376 "cashout_tiny_amount" to "EUR:0.6" 2377 "cashout_rounding_mode" to "zero" 2378 "cashout_min_amount" to "KUDOS:0.7" 2379 } 2380 2381 // Check no classes 2382 client.getAdmin("/conversion-rate-classes").assertNoContent() 2383 client.getAdmin("/conversion-rate-classes/1").assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 2384 client.patchAdmin("/conversion-rate-classes/1") { 2385 json(fullInput) { 2386 "name" to "Class" 2387 } 2388 }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 2389 client.deleteAdmin("/conversion-rate-classes/1").assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 2390 2391 // Create full 2392 val full = client.postAdmin("/conversion-rate-classes") { 2393 json(fullInput) { 2394 "name" to "Class n°1" 2395 } 2396 }.assertOkJson<ConversionRateClassResponse> { 2397 assertEquals(it.conversion_rate_class_id, 1) 2398 val rate = client.getAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}").assertOkJson<ConversionRateClass>() 2399 client.patchAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}") { 2400 json { 2401 "name" to "Class n°1" 2402 } 2403 }.assertNoContent() 2404 it.conversion_rate_class_id 2405 } 2406 // Create empty 2407 val empty = client.postAdmin("/conversion-rate-classes") { 2408 json { 2409 "name" to "Class n°2" 2410 } 2411 }.assertOkJson<ConversionRateClassResponse> { 2412 assertEquals(it.conversion_rate_class_id, 2) 2413 val rate = client.getAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}").assertOkJson<ConversionRateClass>() 2414 client.patchAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}") { 2415 json(fullInput) { 2416 "name" to "Class n°2" 2417 } 2418 }.assertNoContent() 2419 it.conversion_rate_class_id 2420 } 2421 2422 // Bad currency 2423 client.postAdmin("/conversion-rate-classes") { 2424 json(fullInput) { 2425 "name" to "Bad currency" 2426 "cashout_fee" to "CHF:0.003" 2427 } 2428 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 2429 2430 // Name reuse currency 2431 client.postAdmin("/conversion-rate-classes") { 2432 json(fullInput) { 2433 "name" to "Class n°1" 2434 } 2435 }.assertConflict(TalerErrorCode.BANK_NAME_REUSE) 2436 client.patchAdmin("/conversion-rate-classes/2") { 2437 json(fullInput) { 2438 "name" to "Class n°1" 2439 } 2440 }.assertConflict(TalerErrorCode.BANK_NAME_REUSE) 2441 client.patchAdmin("/conversion-rate-classes/1") { 2442 json(fullInput) { 2443 "name" to "Class n°1" 2444 } 2445 }.assertNoContent() 2446 2447 // Page 2448 client.getAdmin("/conversion-rate-classes").assertOkJson<ConversionRateClasses> { 2449 assertEquals(it.classes.size, 2) 2450 } 2451 val generated = (0 until 5).map { createConversionRateClass() } 2452 client.getAdmin("/conversion-rate-classes").assertOkJson<ConversionRateClasses> { 2453 assertEquals(it.classes.size, 7) 2454 } 2455 client.getAdmin("/conversion-rate-classes?filter_name=Gen").assertOkJson<ConversionRateClasses> { 2456 assertEquals(it.classes.size, 5) 2457 } 2458 2459 // Delete all 2460 for (id in listOf(full.conversion_rate_class_id, empty.conversion_rate_class_id) + generated) { 2461 client.deleteAdmin("/conversion-rate-classes/$id").assertNoContent() 2462 client.deleteAdmin("/conversion-rate-classes/$id").assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 2463 } 2464 client.getAdmin("/conversion-rate-classes").assertNoContent() 2465 } 2466 2467 @Test 2468 fun notImplemented() = bankSetup("test_no_conversion.conf") { 2469 client.getAdmin("conversion-rate-classes/1").assertNotImplemented() 2470 client.getAdmin("conversion-rate-classes").assertNotImplemented() 2471 } 2472 }