CoreBankApiTest.kt (92912B)
1 /* 2 * This file is part of LibEuFin. 3 * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A. 4 5 * LibEuFin is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU Affero General Public License as 7 * published by the Free Software Foundation; either version 3, or 8 * (at your option) any later version. 9 10 * LibEuFin is distributed in the hope that it will be useful, but 11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 12 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General 13 * Public License for more details. 14 15 * You should have received a copy of the GNU Affero General Public 16 * License along with LibEuFin; see the file COPYING. If not, see 17 * <http://www.gnu.org/licenses/> 18 */ 19 20 import io.ktor.client.request.* 21 import io.ktor.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", fmtIncomingSubject(IncomingType.reserve, reservePub)) // Accept incoming 1454 tx("merchant", "KUDOS:1", "exchange", fmtIncomingSubject(IncomingType.reserve, 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", fmtOutgoingSubject(wtid, exchange)) // Accept outgoing 1466 tx("exchange", "KUDOS:1", "merchant", fmtOutgoingSubject(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 bad UUID 1700 client.postA("/accounts/merchant/withdrawals/chocolate/abort") 1701 .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) 1702 1703 // Check unknown 1704 client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/abort") 1705 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 1706 } 1707 1708 // POST /accounts/USERNAME/withdrawals/withdrawal_id/confirm 1709 @Test 1710 fun confirm() = bankSetup { 1711 authRoutine(HttpMethod.Post, "/accounts/merchant/withdrawals/42/confirm") 1712 // Check confirm created 1713 client.postA("/accounts/merchant/withdrawals") { 1714 json { "amount" to "KUDOS:1" } 1715 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1716 val uuid = it.withdrawal_id 1717 1718 // Check err 1719 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") 1720 .assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE) 1721 } 1722 1723 // Check confirm selected 1724 client.postA("/accounts/merchant/withdrawals") { 1725 json { "amount" to "KUDOS:1" } 1726 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1727 val uuid = it.withdrawal_id 1728 withdrawalSelect(uuid) 1729 1730 // Check amount differs 1731 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { 1732 json { "amount" to "KUDOS:2" } 1733 }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS) 1734 1735 // Check OK 1736 client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent() 1737 // Check idempotence 1738 client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent() 1739 1740 // Check amount differs 1741 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { 1742 json { "amount" to "KUDOS:2" } 1743 }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS) 1744 } 1745 1746 // Check confirm with amount 1747 client.postA("/accounts/merchant/withdrawals") { 1748 json {} 1749 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1750 val uuid = it.withdrawal_id 1751 withdrawalSelect(uuid) 1752 1753 // Check missing amount 1754 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") 1755 .assertConflict(TalerErrorCode.BANK_AMOUNT_REQUIRED) 1756 1757 // Check OK 1758 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { 1759 json { "amount" to "KUDOS:1" } 1760 }.assertNoContent() 1761 // Check idempotence 1762 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { 1763 json { "amount" to "KUDOS:1" } 1764 }.assertNoContent() 1765 1766 // Check amount differs 1767 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { 1768 json { "amount" to "KUDOS:2" } 1769 }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS) 1770 } 1771 1772 // Check confirm aborted 1773 client.postA("/accounts/merchant/withdrawals") { 1774 json { "amount" to "KUDOS:1" } 1775 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1776 val uuid = it.withdrawal_id 1777 withdrawalSelect(uuid) 1778 client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() 1779 1780 // Check error 1781 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") 1782 .assertConflict(TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT) 1783 } 1784 1785 // Check reserve pub reuse 1786 client.postA("/accounts/merchant/withdrawals") { 1787 json { "amount" to "KUDOS:5" } 1788 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1789 val uuid = it.withdrawal_id 1790 val reservePub = withdrawalSelect(uuid) 1791 1792 tx("customer", "KUDOS:5", "exchange", "Taler $reservePub") 1793 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") 1794 .assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) 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 bad UUID 1814 client.postA("/accounts/merchant/withdrawals/chocolate/confirm") 1815 .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) 1816 1817 // Check unknown 1818 client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/confirm") 1819 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 1820 1821 // Check 2fa without body 1822 fillTanInfo("merchant") 1823 assertBalance("merchant", "-KUDOS:7") 1824 client.postA("/accounts/merchant/withdrawals") { 1825 json { "amount" to "KUDOS:1" } 1826 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1827 val uuid = it.withdrawal_id 1828 withdrawalSelect(uuid) 1829 1830 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") 1831 .assertChallenge { 1832 assertBalance("merchant", "-KUDOS:7") 1833 }.assertNoContent() 1834 } 1835 1836 // Check 2fa with body 1837 fillTanInfo("merchant") 1838 assertBalance("merchant", "-KUDOS:8") 1839 client.postA("/accounts/merchant/withdrawals") { 1840 json {} 1841 }.assertOkJson<BankAccountCreateWithdrawalResponse> { 1842 val uuid = it.withdrawal_id 1843 withdrawalSelect(uuid) 1844 1845 client.postA("/accounts/merchant/withdrawals/$uuid/confirm") { 1846 json { "amount" to "KUDOS:1" } 1847 } 1848 .assertChallenge { 1849 assertBalance("merchant", "-KUDOS:8") 1850 }.assertNoContent() 1851 } 1852 assertBalance("merchant", "-KUDOS:9") 1853 } 1854 1855 @Test 1856 fun confirmWithFee() = bankSetup(conf = "test_with_fees.conf") { db -> 1857 suspend fun run(amount: TalerAmount): HttpResponse { 1858 val uuid = UUID.randomUUID() 1859 // Create a selected withdrawal directly in the database to bypass checks 1860 db.serializable(""" 1861 INSERT INTO taler_withdrawal_operations(withdrawal_uuid,amount,exchange_bank_account,selection_done,wallet_bank_account,creation_date) 1862 VALUES (?, (?, ?)::taler_amount, 2, true, 3, 0) 1863 """) { 1864 bind(uuid) 1865 bind(amount) 1866 executeUpdate() 1867 } 1868 1869 return client.postA("/accounts/customer/withdrawals/$uuid/confirm") 1870 } 1871 1872 // Check insufficient fund 1873 for (amount in listOf("KUDOS:11", "KUDOS:10", "KUDOS:0", "KUDOS:150")) { 1874 run(TalerAmount(amount)).assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) 1875 } 1876 1877 // Check OK 1878 run(TalerAmount("KUDOS:9.9")) 1879 } 1880 } 1881 1882 class CoreBankCashoutApiTest { 1883 // POST /accounts/{USERNAME}/cashouts 1884 @Test 1885 fun create() = bankSetup { 1886 authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts") 1887 1888 val req = obj { 1889 "request_uid" to ShortHashCode.rand() 1890 "amount_debit" to "KUDOS:1" 1891 "amount_credit" to convert("KUDOS:1") 1892 } 1893 1894 // Missing info 1895 client.postA("/accounts/customer/cashouts") { 1896 json(req) 1897 }.assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE) 1898 1899 fillCashoutInfo("customer") 1900 1901 // Check OK 1902 val id = client.postA("/accounts/customer/cashouts") { 1903 json(req) 1904 }.assertOkJson<CashoutResponse>().cashout_id 1905 1906 // Check idempotent 1907 client.postA("/accounts/customer/cashouts") { 1908 json(req) 1909 }.assertOkJson<CashoutResponse> { 1910 assertEquals(id, it.cashout_id) 1911 } 1912 1913 // Trigger conflict due to reused request_uid 1914 client.postA("/accounts/customer/cashouts") { 1915 json(req) { 1916 "amount_debit" to "KUDOS:2" 1917 "amount_credit" to convert("KUDOS:2") 1918 } 1919 }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) 1920 1921 // Check exchange account 1922 client.postA("/accounts/exchange/cashouts") { 1923 json(req) 1924 }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE) 1925 1926 // Check insufficient fund 1927 client.postA("/accounts/customer/cashouts") { 1928 json(req) { 1929 "request_uid" to ShortHashCode.rand() 1930 "amount_debit" to "KUDOS:75" 1931 "amount_credit" to convert("KUDOS:75") 1932 } 1933 }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) 1934 1935 // Check wrong conversion 1936 client.postA("/accounts/customer/cashouts") { 1937 json(req) { 1938 "amount_credit" to convert("KUDOS:2") 1939 } 1940 }.assertConflict(TalerErrorCode.BANK_BAD_CONVERSION) 1941 1942 // Check min amount 1943 client.postA("/accounts/customer/cashouts") { 1944 json(req) { 1945 "amount_debit" to "KUDOS:0.09" 1946 } 1947 }.assertConflict(TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL) 1948 1949 // Check custom min account 1950 createConversionRateClass(cashout_min_amount = TalerAmount("KUDOS:10")) 1951 client.patchAdmin("/accounts/customer") { 1952 json { 1953 "conversion_rate_class_id" to 1 1954 } 1955 }.assertNoContent() 1956 client.postA("/accounts/customer/cashouts") { 1957 json(req) { 1958 "amount_debit" to "KUDOS:5" 1959 "amount_credit" to convert("KUDOS:5") 1960 } 1961 }.assertConflict(TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL) 1962 client.patchAdmin("/accounts/customer") { 1963 json { 1964 "conversion_rate_class_id" to (null as Long?) 1965 } 1966 }.assertNoContent() 1967 1968 // Check wrong currency 1969 client.postA("/accounts/customer/cashouts") { 1970 json(req) { 1971 "amount_debit" to "EUR:1" 1972 } 1973 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 1974 client.postA("/accounts/customer/cashouts") { 1975 json(req) { 1976 "amount_credit" to "KUDOS:1" 1977 } 1978 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 1979 1980 // Check 2fa 1981 fillTanInfo("customer") 1982 assertBalance("customer", "-KUDOS:1") 1983 client.postA("/accounts/customer/cashouts") { 1984 json(req) { 1985 "request_uid" to ShortHashCode.rand() 1986 } 1987 }.assertChallenge { 1988 assertBalance("customer", "-KUDOS:1") 1989 }.assertOkJson<CashoutResponse> { 1990 assertBalance("customer", "-KUDOS:2") 1991 } 1992 } 1993 1994 // GET /accounts/{USERNAME}/cashouts/{CASHOUT_ID} 1995 @Test 1996 fun get() = bankSetup { 1997 authRoutine(HttpMethod.Get, "/accounts/merchant/cashouts/42", allowAdmin = true) 1998 fillCashoutInfo("customer") 1999 2000 val amountDebit = TalerAmount("KUDOS:1.5") 2001 val amountCredit = convert("KUDOS:1.5") 2002 val req = obj { 2003 "amount_debit" to amountDebit 2004 "amount_credit" to amountCredit 2005 } 2006 2007 // Check confirm 2008 client.postA("/accounts/customer/cashouts") { 2009 json(req) { "request_uid" to ShortHashCode.rand() } 2010 }.assertOkJson<CashoutResponse> { 2011 val id = it.cashout_id 2012 client.getA("/accounts/customer/cashouts/$id") 2013 .assertOkJson<CashoutStatusResponse> { 2014 assertEquals(amountDebit, it.amount_debit) 2015 assertEquals(amountCredit, it.amount_credit) 2016 } 2017 } 2018 2019 // Check bad UUID 2020 client.getA("/accounts/customer/cashouts/chocolate") 2021 .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) 2022 2023 // Check unknown 2024 client.getA("/accounts/customer/cashouts/42") 2025 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 2026 2027 // Check get another user's operation 2028 client.postA("/accounts/customer/cashouts") { 2029 json(req) { "request_uid" to ShortHashCode.rand() } 2030 }.assertOkJson<CashoutResponse> { 2031 val id = it.cashout_id 2032 2033 // Check error 2034 client.getA("/accounts/merchant/cashouts/$id") 2035 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 2036 } 2037 } 2038 2039 // GET /accounts/{USERNAME}/cashouts 2040 @Test 2041 fun history() = bankSetup { 2042 authRoutine(HttpMethod.Get, "/accounts/merchant/cashouts", allowAdmin = true) 2043 historyRoutine<Cashouts>( 2044 url = "/accounts/customer/cashouts", 2045 ids = { it.cashouts.map { it.cashout_id } }, 2046 registered = listOf { cashout("KUDOS:0.1") }, 2047 polling = false 2048 ) 2049 } 2050 2051 // GET /cashouts 2052 @Test 2053 fun globalHistory() = bankSetup { 2054 authRoutine(HttpMethod.Get, "/cashouts", requireAdmin = true) 2055 historyRoutine<GlobalCashouts>( 2056 url = "/cashouts", 2057 ids = { it.cashouts.map { it.cashout_id } }, 2058 registered = listOf { cashout("KUDOS:0.1") }, 2059 polling = false, 2060 auth = "admin" 2061 ) 2062 } 2063 2064 @Test 2065 fun notImplemented() = bankSetup("test_no_conversion.conf") { 2066 client.get("/accounts/customer/cashouts") 2067 .assertNotImplemented() 2068 } 2069 } 2070 2071 class CoreBankTanApiTest { 2072 // POST /accounts/{USERNAME}/challenge/{challenge_id} 2073 @Test 2074 fun send() = bankSetup { 2075 suspend fun HttpResponse.expectMfa(vararg tans: Pair<TanChannel, String>): HttpResponse { 2076 return assertChallenge { res -> 2077 assertEquals(setOf(*tans), res.challenges.map { it.tan_channel to it.tan_info }.toSet()) 2078 assertFalse(res.combi_and) 2079 } 2080 } 2081 suspend fun HttpResponse.expectValidation(vararg tans: Pair<TanChannel, String>): HttpResponse { 2082 return assertChallenge { res -> 2083 assertEquals(setOf(*tans), res.challenges.map { it.tan_channel to it.tan_info }.toSet()) 2084 assertTrue(res.combi_and) 2085 } 2086 } 2087 2088 // Set up 2fa 2089 client.patchA("/accounts/merchant") { 2090 json { 2091 "contact_data" to obj { 2092 "phone" to "+99" 2093 "email" to "email@example.com" 2094 } 2095 "tan_channel" to "sms" 2096 } 2097 }.expectValidation(TanChannel.sms to "+99") 2098 .assertNoContent() 2099 2100 // Update 2fa settings - first 2FA challenge then new tan channel check 2101 client.patchA("/accounts/merchant") { 2102 json { // Info change 2103 "contact_data" to obj { "phone" to "+98" } 2104 } 2105 }.expectValidation(TanChannel.sms to "+99", TanChannel.sms to "+98") 2106 .assertNoContent() 2107 client.patchA("/accounts/merchant") { 2108 json { // Channel change 2109 "tan_channel" to "email" 2110 } 2111 }.expectValidation(TanChannel.sms to "+98", TanChannel.email to "email@example.com") 2112 .assertNoContent() 2113 client.patchA("/accounts/merchant") { 2114 json { // Both change 2115 "contact_data" to obj { "phone" to "+97" } 2116 "tan_channel" to "sms" 2117 } 2118 }.expectValidation(TanChannel.email to "email@example.com", TanChannel.sms to "+97") 2119 .assertNoContent() 2120 2121 // Disable 2fa 2122 client.patchA("/accounts/merchant") { 2123 json { "tan_channel" to null as String? } 2124 }.expectValidation(TanChannel.sms to "+97") 2125 .assertNoContent() 2126 2127 // Update mfa settings - first mfa challenge then new tan channel check 2128 client.patchA("/accounts/merchant") { 2129 json { // All channels 2130 "tan_channels" to setOf("sms", "email") 2131 } 2132 }.expectValidation(TanChannel.sms to "+97", TanChannel.email to "email@example.com") 2133 .assertNoContent() 2134 client.patchA("/accounts/merchant") { 2135 json { // All info changes 2136 "contact_data" to obj { 2137 "phone" to "+99" 2138 "email" to "email2@example.com" 2139 } 2140 } 2141 }.expectMfa(TanChannel.sms to "+97", TanChannel.email to "email@example.com") 2142 .expectValidation(TanChannel.sms to "+99", TanChannel.email to "email2@example.com") 2143 .assertNoContent() 2144 2145 // Disable mfa 2146 client.patchA("/accounts/merchant") { 2147 json { "tan_channels" to emptySet<String>() } 2148 }.expectMfa(TanChannel.sms to "+99", TanChannel.email to "email2@example.com") 2149 .assertNoContent() 2150 2151 2152 // Admin has no 2FA 2153 client.patchAdmin("/accounts/merchant") { 2154 json { 2155 "contact_data" to obj { "phone" to "+99" } 2156 "tan_channel" to "sms" 2157 } 2158 }.assertNoContent() 2159 client.patchAdmin("/accounts/merchant") { 2160 json { "tan_channel" to "email" } 2161 }.assertNoContent() 2162 client.patchAdmin("/accounts/merchant") { 2163 json { "tan_channel" to null as String? } 2164 }.assertNoContent() 2165 2166 // Check retry and invalidate 2167 client.patchA("/accounts/merchant") { 2168 json { 2169 "contact_data" to obj { "phone" to "+88" } 2170 "tan_channel" to "sms" 2171 } 2172 }.assertChallenge().assertNoContent() 2173 client.patchA("/accounts/merchant") { 2174 json { "is_public" to false } 2175 }.assertAcceptedJson<ChallengeResponse> { 2176 val challenge = it.challenges[0] 2177 // Check ok 2178 client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") 2179 .assertOk() 2180 val code = tanCode("+88") 2181 assertNotNull(code) 2182 // Check retry 2183 client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") 2184 .assertOk() 2185 assertNull(tanCode("+88")) 2186 // Idempotent patch does nothing 2187 client.patchA("/accounts/merchant") { 2188 json { 2189 "contact_data" to obj { "phone" to "+88" } 2190 "tan_channel" to "sms" 2191 } 2192 } 2193 client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") 2194 .assertOk() 2195 assertNull(tanCode("+88")) 2196 2197 // Change 2fa settings 2198 client.patchA("/accounts/merchant") { 2199 json { 2200 "tan_channel" to "email" 2201 } 2202 }.expectValidation(TanChannel.sms to "+88", TanChannel.email to "email2@example.com") 2203 .assertNoContent() 2204 2205 // Check invalidated 2206 client.postA("/accounts/merchant/challenge/${challenge.challenge_id}/confirm") { 2207 json { "tan" to code } 2208 }.assertNotFound(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED) 2209 client.patchA("/accounts/merchant") { 2210 headers[TALER_CHALLENGE_IDS] = "${challenge.challenge_id}" 2211 json { "is_public" to false } 2212 }.expectValidation(TanChannel.email to "email2@example.com") 2213 .assertNoContent() 2214 } 2215 2216 // Unknown challenge 2217 client.postA("/accounts/merchant/challenge/${UUID.randomUUID()}") 2218 .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 2219 } 2220 2221 @Test 2222 fun sendRateLimited() = bankSetup { 2223 fillTanInfo("merchant") 2224 2225 suspend fun ApplicationTestBuilder.txChallenge() 2226 = client.postA("/accounts/merchant/transactions") { 2227 json { 2228 "payto_uri" to "$customerPayto?message=tx&amount=KUDOS:0.1" 2229 } 2230 }.assertAcceptedJson<ChallengeResponse>().challenges[0] 2231 suspend fun ApplicationTestBuilder.submit(challenge: Challenge) 2232 = client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") 2233 .assertOkJson<ChallengeRequestResponse>() 2234 2235 2236 // Start a legitimate challenge and submit it 2237 val oldChallenge = txChallenge() 2238 submit(oldChallenge) 2239 val tanCode = tanCode(oldChallenge.tan_info) 2240 2241 // Challenge creation is not rate limited 2242 repeat(MAX_ACTIVE_CHALLENGES*2) { 2243 txChallenge() 2244 } 2245 2246 // Challenge submission is rate limited 2247 repeat(MAX_ACTIVE_CHALLENGES-1) { 2248 submit(txChallenge()) 2249 } 2250 val challenge = txChallenge() 2251 client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") 2252 .assertTooManyRequests(TalerErrorCode.BANK_TAN_RATE_LIMITED) 2253 2254 // Old already submitted challenge still works 2255 val transmission = submit(oldChallenge) 2256 client.postA("/accounts/merchant/challenge/${oldChallenge.challenge_id}/confirm") { 2257 json { "tan" to tanCode } 2258 }.assertNoContent() 2259 2260 // Now an active challenge slot have been freed 2261 submit(challenge) 2262 2263 // We are rate limited again 2264 val newChallenge = txChallenge() 2265 client.postA("/accounts/merchant/challenge/${newChallenge.challenge_id}") 2266 .assertTooManyRequests(TalerErrorCode.BANK_TAN_RATE_LIMITED) 2267 } 2268 2269 // POST /accounts/{USERNAME}/challenge/{challenge_id} 2270 @Test 2271 fun sendTanErr() = bankSetup("test_tan_err.conf") { 2272 // Check fail 2273 fillTanInfo("merchant") 2274 client.patchA("/accounts/merchant") { 2275 json { "is_public" to false } 2276 }.assertAcceptedJson<ChallengeResponse> { 2277 val challenge = it.challenges[0] 2278 client.postA("/accounts/merchant/challenge/${challenge.challenge_id}") 2279 .assertStatus(HttpStatusCode.BadGateway, TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED) 2280 } 2281 } 2282 2283 // POST /accounts/{USERNAME}/challenge/{challenge_id}/confirm 2284 @Test 2285 fun confirm() = bankSetup { 2286 fillTanInfo("merchant") 2287 2288 // Check simple case 2289 client.patchA("/accounts/merchant") { 2290 json { "is_public" to false } 2291 }.assertAcceptedJson<ChallengeResponse> { 2292 val challenge = it.challenges[0] 2293 val id = challenge.challenge_id 2294 client.postA("/accounts/merchant/challenge/$id") 2295 .assertOkJson<ChallengeRequestResponse>() 2296 val code = tanCode(challenge.tan_info) 2297 2298 // Check bad TAN code 2299 client.postA("/accounts/merchant/challenge/$id/confirm") { 2300 json { "tan" to "nice-try" } 2301 }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED) 2302 2303 // Check wrong account 2304 client.postA("/accounts/customer/challenge/$id/confirm") { 2305 json { "tan" to "nice-try" } 2306 }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED) 2307 2308 // Check OK 2309 client.postA("/accounts/merchant/challenge/$id/confirm") { 2310 json { "tan" to code } 2311 }.assertNoContent() 2312 // Check idempotence 2313 client.postA("/accounts/merchant/challenge/$id/confirm") { 2314 json { "tan" to code } 2315 }.assertNoContent() 2316 2317 // Unknown challenge 2318 client.postA("/accounts/merchant/challenge/${UUID.randomUUID()}/confirm") { 2319 json { "tan" to code } 2320 }.assertNotFound(TalerErrorCode.BANK_CHALLENGE_NOT_FOUND) 2321 } 2322 2323 // Check invalidation 2324 client.patchA("/accounts/merchant") { 2325 json { "is_public" to true } 2326 }.assertAcceptedJson<ChallengeResponse> { 2327 val challenge = it.challenges[0] 2328 val id = challenge.challenge_id 2329 client.postA("/accounts/merchant/challenge/$id") 2330 .assertOkJson<ChallengeRequestResponse>() 2331 2332 // Check invalidated 2333 fillTanInfo("merchant") 2334 client.postA("/accounts/merchant/challenge/$id/confirm") { 2335 json { "tan" to tanCode(challenge.tan_info) } 2336 }.assertNotFound(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED) 2337 2338 client.postA("/accounts/merchant/challenge/$id") 2339 .assertNotFound(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED) 2340 } 2341 } 2342 } 2343 2344 class CoreBankConversionApiTest { 2345 // POST /conversion-rate-classes 2346 // GET /conversion-rate-classes 2347 // GET /conversion-rate-classes/{CLASS_ID} 2348 @Test 2349 fun classes() = bankSetup() { 2350 authRoutine(HttpMethod.Post, "/conversion-rate-classes", requireAdmin = true) 2351 authRoutine(HttpMethod.Get, "/conversion-rate-classes", requireAdmin = true) 2352 authRoutine(HttpMethod.Get, "/conversion-rate-classes/1", requireAdmin = true) 2353 2354 val fullInput = obj { 2355 "description" to "A nice little class" 2356 "cashin_ratio" to "0.1" 2357 "cashin_fee" to "KUDOS:0.2" 2358 "cashin_tiny_amount" to "KUDOS:0.3" 2359 "cashin_rounding_mode" to "nearest" 2360 "cashin_min_amount" to "EUR:0" 2361 "cashout_ratio" to "0.4" 2362 "cashout_fee" to "EUR:0.5" 2363 "cashout_tiny_amount" to "EUR:0.6" 2364 "cashout_rounding_mode" to "zero" 2365 "cashout_min_amount" to "KUDOS:0.7" 2366 } 2367 2368 // Check no classes 2369 client.getAdmin("/conversion-rate-classes").assertNoContent() 2370 client.getAdmin("/conversion-rate-classes/1").assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 2371 client.patchAdmin("/conversion-rate-classes/1") { 2372 json(fullInput) { 2373 "name" to "Class" 2374 } 2375 }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 2376 client.deleteAdmin("/conversion-rate-classes/1").assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 2377 2378 // Create full 2379 val full = client.postAdmin("/conversion-rate-classes") { 2380 json(fullInput) { 2381 "name" to "Class n°1" 2382 } 2383 }.assertOkJson<ConversionRateClassResponse> { 2384 assertEquals(it.conversion_rate_class_id, 1) 2385 val rate = client.getAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}").assertOkJson<ConversionRateClass>() 2386 client.patchAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}") { 2387 json { 2388 "name" to "Class n°1" 2389 } 2390 }.assertNoContent() 2391 it.conversion_rate_class_id 2392 } 2393 // Create empty 2394 val empty = client.postAdmin("/conversion-rate-classes") { 2395 json { 2396 "name" to "Class n°2" 2397 } 2398 }.assertOkJson<ConversionRateClassResponse> { 2399 assertEquals(it.conversion_rate_class_id, 2) 2400 val rate = client.getAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}").assertOkJson<ConversionRateClass>() 2401 client.patchAdmin("/conversion-rate-classes/${it.conversion_rate_class_id}") { 2402 json(fullInput) { 2403 "name" to "Class n°2" 2404 } 2405 }.assertNoContent() 2406 it.conversion_rate_class_id 2407 } 2408 2409 // Bad currency 2410 client.postAdmin("/conversion-rate-classes") { 2411 json(fullInput) { 2412 "name" to "Bad currency" 2413 "cashout_fee" to "CHF:0.003" 2414 } 2415 }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) 2416 2417 // Name reuse currency 2418 client.postAdmin("/conversion-rate-classes") { 2419 json(fullInput) { 2420 "name" to "Class n°1" 2421 } 2422 }.assertConflict(TalerErrorCode.BANK_NAME_REUSE) 2423 client.patchAdmin("/conversion-rate-classes/2") { 2424 json(fullInput) { 2425 "name" to "Class n°1" 2426 } 2427 }.assertConflict(TalerErrorCode.BANK_NAME_REUSE) 2428 client.patchAdmin("/conversion-rate-classes/1") { 2429 json(fullInput) { 2430 "name" to "Class n°1" 2431 } 2432 }.assertNoContent() 2433 2434 // Page 2435 client.getAdmin("/conversion-rate-classes").assertOkJson<ConversionRateClasses> { 2436 assertEquals(it.classes.size, 2) 2437 } 2438 val generated = (0 until 5).map { createConversionRateClass() } 2439 client.getAdmin("/conversion-rate-classes").assertOkJson<ConversionRateClasses> { 2440 assertEquals(it.classes.size, 7) 2441 } 2442 client.getAdmin("/conversion-rate-classes?filter_name=Gen").assertOkJson<ConversionRateClasses> { 2443 assertEquals(it.classes.size, 5) 2444 } 2445 2446 // Delete all 2447 for (id in listOf(full.conversion_rate_class_id, empty.conversion_rate_class_id) + generated) { 2448 client.deleteAdmin("/conversion-rate-classes/$id").assertNoContent() 2449 client.deleteAdmin("/conversion-rate-classes/$id").assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) 2450 } 2451 client.getAdmin("/conversion-rate-classes").assertNoContent() 2452 } 2453 2454 @Test 2455 fun notImplemented() = bankSetup("test_no_conversion.conf") { 2456 client.getAdmin("conversion-rate-classes/1").assertNotImplemented() 2457 client.getAdmin("conversion-rate-classes").assertNotImplemented() 2458 } 2459 }