libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

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 }