libeufin

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

CoreBankApiTest.kt (98526B)


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