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