/* * This file is part of LibEuFin. * Copyright (C) 2023 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation; either version 3, or * (at your option) any later version. * LibEuFin is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General * Public License for more details. * You should have received a copy of the GNU Affero General Public * License along with LibEuFin; see the file COPYING. If not, see * */ import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.testing.* import kotlinx.serialization.json.JsonElement import org.junit.Test import tech.libeufin.bank.* import tech.libeufin.common.* import java.time.Duration import java.time.Instant import java.util.* import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull class CoreBankConfigTest { // GET /config @Test fun config() = bankSetup { _ -> client.get("/config").assertOk() } // GET /monitor @Test fun monitor() = bankSetup { _ -> authRoutine(HttpMethod.Get, "/monitor", requireAdmin = true) // Check OK client.get("/monitor?timeframe=day&which=25") { pwAuth("admin") }.assertOk() client.get("/monitor?timeframe=day=which=25") { pwAuth("admin") }.assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) } } class CoreBankTokenApiTest { // POST /accounts/USERNAME/token @Test fun post() = bankSetup { db -> authRoutine(HttpMethod.Post, "/accounts/merchant/token") // New default token client.postA("/accounts/merchant/token") { json { "scope" to "readonly" } }.assertOkJson { // Checking that the token lifetime defaulted to 24 hours. val token = db.token.get(Base32Crockford.decode(it.access_token)) val lifeTime = Duration.between(token!!.creationTime, token.expirationTime) assertEquals(Duration.ofDays(1), lifeTime) } // Check default duration client.postA("/accounts/merchant/token") { json { "scope" to "readonly" } }.assertOkJson { // Checking that the token lifetime defaulted to 24 hours. val token = db.token.get(Base32Crockford.decode(it.access_token)) val lifeTime = Duration.between(token!!.creationTime, token.expirationTime) assertEquals(Duration.ofDays(1), lifeTime) } // Check refresh client.postA("/accounts/merchant/token") { json { "scope" to "readonly" "refreshable" to true } }.assertOkJson { val token = it.access_token client.post("/accounts/merchant/token") { headers["Authorization"] = "Bearer secret-token:$token" json { "scope" to "readonly" } }.assertOk() } // Check'forever' case. client.postA("/accounts/merchant/token") { json { "scope" to "readonly" "duration" to obj { "d_us" to "forever" } } }.run { val never: TokenSuccessResponse = json() assertEquals(Instant.MAX, never.expiration.t_s) } // Check too big or invalid durations client.postA("/accounts/merchant/token") { json { "scope" to "readonly" "duration" to obj { "d_us" to "invalid" } } }.assertBadRequest() client.postA("/accounts/merchant/token") { json { "scope" to "readonly" "duration" to obj { "d_us" to Long.MAX_VALUE } } }.assertBadRequest() client.postA("/accounts/merchant/token") { json { "scope" to "readonly" "duration" to obj { "d_us" to -1 } } }.assertBadRequest() } // DELETE /accounts/USERNAME/token @Test fun delete() = bankSetup { _ -> // TODO test restricted val token = client.post("/accounts/merchant/token") { pwAuth("merchant") json { "scope" to "readonly" } }.assertOkJson().access_token // Check OK client.delete("/accounts/merchant/token") { headers["Authorization"] = "Bearer secret-token:$token" }.assertNoContent() // Check token no longer work client.delete("/accounts/merchant/token") { headers["Authorization"] = "Bearer secret-token:$token" }.assertUnauthorized() // Checking merchant can still be served by basic auth, after token deletion. client.get("/accounts/merchant") { pwAuth("merchant") }.assertOk() } } class CoreBankAccountsApiTest { // Testing the account creation and its idempotency @Test fun create() = bankSetup { _ -> // Check generated payto obj { "username" to "john" "password" to "password" "name" to "John" }.let { req -> // Check Ok val payto = client.post("/accounts") { json(req) }.assertOkJson().internal_payto_uri // Check idempotency client.post("/accounts") { json(req) }.assertOkJson { assertEquals(payto, it.internal_payto_uri) } // Check idempotency with payto client.post("/accounts") { json(req) { "payto_uri" to payto } }.assertOk() // Check payto conflict client.post("/accounts") { json(req) { "payto_uri" to IbanPayto.rand() } }.assertConflict(TalerErrorCode.BANK_REGISTER_USERNAME_REUSE) } // Check given payto val payto = IbanPayto.rand() val req = obj { "username" to "foo" "password" to "password" "name" to "Jane" "is_public" to true "payto_uri" to payto "is_taler_exchange" to true } // Check Ok client.post("/accounts") { json(req) }.assertOkJson { assertEquals(payto.full("Jane"), it.internal_payto_uri) } // Testing idempotency client.post("/accounts") { json(req) }.assertOkJson { assertEquals(payto.full("Jane"), it.internal_payto_uri) } // Check admin only debit_threshold obj { "username" to "bat" "password" to "password" "name" to "Bat" "debit_threshold" to "KUDOS:42" }.let { req -> client.post("/accounts") { json(req) }.assertConflict(TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT) client.post("/accounts") { json(req) pwAuth("admin") }.assertOk() } // Check admin only tan_channel obj { "username" to "bat2" "password" to "password" "name" to "Bat" "contact_data" to obj { "phone" to "+456" } "tan_channel" to "sms" }.let { req -> client.post("/accounts") { json(req) }.assertConflict(TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL) client.post("/accounts") { json(req) pwAuth("admin") }.assertOk() } // Check tan info for (channel in listOf("sms", "email")) { client.post("/accounts") { pwAuth("admin") json { "username" to "bat2" "password" to "password" "name" to "Bat" "tan_channel" to channel } }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO) } // Reserved account RESERVED_ACCOUNTS.forEach { client.post("/accounts") { json { "username" to it "password" to "password" "name" to "John Smith" } }.assertConflict(TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT) } // Non exchange account client.post("/accounts") { json { "username" to "exchange" "password" to "password" "name" to "Exchange" } }.assertConflict(TalerErrorCode.END) // Testing login conflict client.post("/accounts") { json(req) { "name" to "Foo" } }.assertConflict(TalerErrorCode.BANK_REGISTER_USERNAME_REUSE) // Testing payto conflict client.post("/accounts") { json(req) { "username" to "bar" } }.assertConflict(TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE) client.get("/accounts/bar") { pwAuth("admin") }.assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) // Testing bad payto kind client.post("/accounts") { json(req) { "username" to "bar" "password" to "bar-password" "name" to "Mr Bar" "payto_uri" to "payto://x-taler-bank/bank.hostname.test/bar" } }.assertBadRequest() // Check cashout payto receiver name logic client.post("/accounts") { json { "username" to "cashout_guess" "password" to "cashout_guess-password" "name" to "Mr Guess My Name" "cashout_payto_uri" to payto } }.assertOk() client.getA("/accounts/cashout_guess").assertOkJson { assertEquals(payto.full("Mr Guess My Name"), it.cashout_payto_uri) } val full = payto.full("Santa Claus") client.post("/accounts") { json { "username" to "cashout_keep" "password" to "cashout_keep-password" "name" to "Mr Keep My Name" "cashout_payto_uri" to full } }.assertOk() client.getA("/accounts/cashout_keep").assertOkJson { assertEquals(full, it.cashout_payto_uri) } } // Test account created with bonus @Test fun createBonus() = bankSetup(conf = "test_bonus.conf") { _ -> val req = obj { "username" to "foo" "password" to "xyz" "name" to "Mallory" } setMaxDebt("admin", "KUDOS:10000") // Check ok repeat(100) { client.post("/accounts") { pwAuth("admin") json(req) { "username" to "foo$it" } }.assertOk() assertBalance("foo$it", "+KUDOS:100") } assertBalance("admin", "-KUDOS:10000") // Check insufficient fund client.post("/accounts") { pwAuth("admin") json(req) { "username" to "bar" } }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) client.get("/accounts/bar") { pwAuth("admin") }.assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) } // Test admin-only account creation @Test fun createRestricted() = bankSetup(conf = "test_restrict.conf") { _ -> authRoutine(HttpMethod.Post, "/accounts", requireAdmin = true) client.post("/accounts") { pwAuth("admin") json { "username" to "baz" "password" to "xyz" "name" to "Mallory" } }.assertOk() } // Test admin-only account creation @Test fun createTanErr() = bankSetup(conf = "test_tan_err.conf") { _ -> client.post("/accounts") { pwAuth("admin") json { "username" to "baz" "password" to "xyz" "name" to "Mallory" "tan_channel" to "email" } }.assertConflict(TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED) } // DELETE /accounts/USERNAME @Test fun delete() = bankSetup { db -> authRoutine(HttpMethod.Delete, "/accounts/merchant", allowAdmin = true) // Reserved account RESERVED_ACCOUNTS.forEach { client.delete("/accounts/$it") { pwAuth("admin") }.assertConflict(TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT) } client.deleteA("/accounts/exchange") .assertConflict(TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT) client.post("/accounts") { json { "username" to "john" "password" to "john-password" "name" to "John" "payto_uri" to genTmpPayTo() } }.assertOk() fillTanInfo("john") // Fail to delete, due to a non-zero balance. tx("customer", "KUDOS:1", "john") client.deleteA("/accounts/john") .assertConflict(TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO) // Successful deletion tx("john", "KUDOS:1", "customer") // TODO remove with gc db.conn { conn -> val id = conn.prepareStatement("SELECT bank_account_id FROM bank_accounts JOIN customers ON customer_id=owning_customer_id WHERE login = ?").run { setString(1, "john") oneOrNull { it.getLong(1) }!! } conn.prepareStatement("DELETE FROM bank_account_transactions WHERE bank_account_id=?").run { setLong(1, id) execute() } } client.deleteA("/accounts/john") .assertChallenge() .assertNoContent() // Account no longer exists client.delete("/accounts/john") { pwAuth("admin") }.assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) } // Test admin-only account deletion @Test fun deleteRestricted() = bankSetup(conf = "test_restrict.conf") { _ -> authRoutine(HttpMethod.Post, "/accounts", requireAdmin = true) // Exchange is still restricted client.delete("/accounts/exchange") { pwAuth("admin") }.assertConflict(TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT) } // Test delete exchange account @Test fun deleteNoConversion() = bankSetup(conf = "test_no_conversion.conf") { _ -> // Exchange is no longer restricted client.deleteA("/accounts/exchange").assertNoContent() } suspend fun ApplicationTestBuilder.checkAdminOnly( req: JsonElement, error: TalerErrorCode ) { // Check restricted client.patchA("/accounts/merchant") { json(req) }.assertConflict(error) // Check admin always can client.patch("/accounts/merchant") { pwAuth("admin") json(req) }.assertNoContent() // Check idempotent client.patchA("/accounts/merchant") { json(req) }.assertNoContent() } // PATCH /accounts/USERNAME @Test fun reconfig() = bankSetup { _ -> authRoutine(HttpMethod.Patch, "/accounts/merchant", allowAdmin = true) // Check tan info for (channel in listOf("sms", "email")) { client.patchA("/accounts/merchant") { json { "tan_channel" to channel } }.assertConflict(TalerErrorCode.BANK_MISSING_TAN_INFO) } // Successful attempt now val cashout = IbanPayto.rand() val req = obj { "cashout_payto_uri" to cashout "name" to "Roger" "is_public" to true "contact_data" to obj { "phone" to "+99" "email" to "foo@example.com" } } client.patchA("/accounts/merchant") { json(req) }.assertNoContent() // Checking idempotence client.patchA("/accounts/merchant") { json(req) }.assertNoContent() checkAdminOnly( obj(req) { "debit_threshold" to "KUDOS:100" }, TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT ) // Check currency client.patch("/accounts/merchant") { pwAuth("admin") json(req) { "debit_threshold" to "EUR:100" } }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) // Check patch client.getA("/accounts/merchant").assertOkJson { obj -> assertEquals("Roger", obj.name) assertEquals(cashout.full(obj.name), obj.cashout_payto_uri) assertEquals("+99", obj.contact_data?.phone?.get()) assertEquals("foo@example.com", obj.contact_data?.email?.get()) assertEquals(TalerAmount("KUDOS:100"), obj.debit_threshold) assert(obj.is_public) assert(!obj.is_taler_exchange) } // Check keep values when there is no changes client.patchA("/accounts/merchant") { json { } }.assertNoContent() client.getA("/accounts/merchant").assertOkJson { obj -> assertEquals("Roger", obj.name) assertEquals(cashout.full(obj.name), obj.cashout_payto_uri) assertEquals("+99", obj.contact_data?.phone?.get()) assertEquals("foo@example.com", obj.contact_data?.email?.get()) assertEquals(TalerAmount("KUDOS:100"), obj.debit_threshold) assert(obj.is_public) assert(!obj.is_taler_exchange) } // Admin cannot be public client.patchA("/accounts/admin") { json { "is_public" to true } }.assertConflict(TalerErrorCode.END) // Check cashout payto receiver name logic client.post("/accounts") { json { "username" to "cashout" "password" to "cashout-password" "name" to "Mr Cashout Cashout" } }.assertOk() val canonical = Payto.parse(cashout.canonical).expectIban() for ((cashout, name, expect) in listOf( Triple(cashout.canonical, null, canonical.full("Mr Cashout Cashout")), Triple(cashout.canonical, "New name", canonical.full("New name")), Triple(cashout.full("Full name"), null, cashout.full("Full name")), Triple(cashout.full("Full second name"), "Another name", cashout.full("Full second name")) )) { client.patch("/accounts/cashout") { pwAuth("admin") json { "cashout_payto_uri" to cashout if (name != null) "name" to name } }.assertNoContent() client.getA("/accounts/cashout").assertOkJson { obj -> assertEquals(expect, obj.cashout_payto_uri) } } // Check 2FA fillTanInfo("merchant") client.patchA("/accounts/merchant") { json { "is_public" to false } }.assertChallenge { _, _ -> client.getA("/accounts/merchant").assertOkJson { obj -> assert(obj.is_public) } }.assertNoContent() client.getA("/accounts/merchant").assertOkJson { obj -> assert(!obj.is_public) } } // Test admin-only account patch @Test fun patchRestricted() = bankSetup(conf = "test_restrict.conf") { _ -> // Check restricted checkAdminOnly( obj { "name" to "Another Foo" }, TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME ) checkAdminOnly( obj { "cashout_payto_uri" to IbanPayto.rand() }, TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT ) // Check idempotent client.getA("/accounts/merchant").assertOkJson { obj -> client.patchA("/accounts/merchant") { json { "name" to obj.name "cashout_payto_uri" to obj.cashout_payto_uri "debit_threshold" to obj.debit_threshold } }.assertNoContent() } } // Test TAN check account patch @Test fun patchTanErr() = bankSetup(conf = "test_tan_err.conf") { _ -> // Check unsupported TAN channel client.patchA("/accounts/customer") { json { "tan_channel" to "email" } }.assertConflict(TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED) } // PATCH /accounts/USERNAME/auth @Test fun passwordChange() = bankSetup { _ -> authRoutine(HttpMethod.Patch, "/accounts/merchant/auth", allowAdmin = true) // Changing the password. client.patch("/accounts/customer/auth") { basicAuth("customer", "customer-password") json { "old_password" to "customer-password" "new_password" to "new-password" } }.assertNoContent() // Previous password should fail. client.patch("/accounts/customer/auth") { basicAuth("customer", "customer-password") }.assertUnauthorized() // New password should succeed. client.patch("/accounts/customer/auth") { basicAuth("customer", "new-password") json { "old_password" to "new-password" "new_password" to "customer-password" } }.assertNoContent() // Check require test old password client.patch("/accounts/customer/auth") { basicAuth("customer", "customer-password") json { "old_password" to "bad-password" "new_password" to "new-password" } }.assertConflict(TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD) // Check require old password for user client.patch("/accounts/customer/auth") { basicAuth("customer", "customer-password") json { "new_password" to "new-password" } }.assertConflict(TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD) // Check admin client.patch("/accounts/customer/auth") { pwAuth("admin") json { "new_password" to "customer-password" } }.assertNoContent() // Check 2FA fillTanInfo("customer") client.patchA("/accounts/customer/auth") { json { "old_password" to "customer-password" "new_password" to "it-password" } }.assertChallenge().assertNoContent() client.patch("/accounts/customer/auth") { pwAuth("admin") json { "new_password" to "new-password" } }.assertNoContent() } // GET /public-accounts and GET /accounts @Test fun list() = bankSetup(conf = "test_no_conversion.conf") { _ -> authRoutine(HttpMethod.Get, "/accounts", requireAdmin = true) // Remove default accounts listOf("merchant", "exchange", "customer").forEach { client.delete("/accounts/$it") { pwAuth("admin") }.assertNoContent() } // Check error when no public accounts client.get("/public-accounts").assertNoContent() client.get("/accounts") { pwAuth("admin") }.assertOk() // Gen some public and private accounts repeat(5) { client.post("/accounts") { json { "username" to "$it" "password" to "password" "name" to "Mr $it" "is_public" to (it%2 == 0) } }.assertOk() } // All public client.get("/public-accounts").run { assertOk() val obj = json() assertEquals(3, obj.public_accounts.size) obj.public_accounts.forEach { assertEquals(0, it.username.toInt() % 2) } } // All accounts client.get("/accounts?delta=10"){ pwAuth("admin") }.run { assertOk() val obj = json() assertEquals(6, obj.accounts.size) obj.accounts.forEachIndexed { idx, it -> if (idx == 0) { assertEquals("admin", it.username) } else { assertEquals(idx - 1, it.username.toInt()) } } } // Filtering client.get("/accounts?filter_name=3"){ pwAuth("admin") }.run { assertOk() val obj = json() assertEquals(1, obj.accounts.size) assertEquals("3", obj.accounts[0].username) } } // GET /accounts/USERNAME @Test fun get() = bankSetup { _ -> authRoutine(HttpMethod.Get, "/accounts/merchant", allowAdmin = true) // Check ok client.getA("/accounts/merchant").assertOkJson { assertEquals("Merchant", it.name) } } } class CoreBankTransactionsApiTest { // GET /transactions @Test fun history() = bankSetup { _ -> authRoutine(HttpMethod.Get, "/accounts/merchant/transactions") historyRoutine( url = "/accounts/customer/transactions", ids = { it.transactions.map { it.row_id } }, registered = listOf( { // Transactions from merchant to exchange tx("merchant", "KUDOS:0.1", "customer") }, { // Transactions from exchange to merchant tx("customer", "KUDOS:0.1", "merchant") }, { // Transactions from merchant to exchange tx("merchant", "KUDOS:0.1", "customer") }, { // Cashout from merchant cashout("KUDOS:0.1") } ), ignored = listOf( { // Ignore transactions of other accounts tx("merchant", "KUDOS:0.1", "exchange") }, { // Ignore transactions of other accounts tx("exchange", "KUDOS:0.1", "merchant",) } ) ) } // GET /transactions/T_ID @Test fun testById() = bankSetup { _ -> authRoutine(HttpMethod.Get, "/accounts/merchant/transactions/42") // Create transaction tx("merchant", "KUDOS:0.3", "exchange", "tx") // Check OK client.getA("/accounts/merchant/transactions/1") .assertOkJson { tx -> assertEquals("tx", tx.subject) assertEquals(TalerAmount("KUDOS:0.3"), tx.amount) } // Check unknown transaction client.getA("/accounts/merchant/transactions/3") .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) // Check another user's transaction client.getA("/accounts/merchant/transactions/2") .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } // POST /transactions @Test fun create() = bankSetup { db -> authRoutine(HttpMethod.Post, "/accounts/merchant/transactions") val valid_req = obj { "payto_uri" to "$exchangePayto?message=payout" "amount" to "KUDOS:0.3" } // Check OK client.postA("/accounts/merchant/transactions") { json(valid_req) }.assertOkJson { client.getA("/accounts/merchant/transactions/${it.row_id}") .assertOkJson { tx -> assertEquals("payout", tx.subject) assertEquals(TalerAmount("KUDOS:0.3"), tx.amount) } } // Check idempotency ShortHashCode.rand().let { requestUid -> val id = client.postA("/accounts/merchant/transactions") { json(valid_req) { "request_uid" to requestUid } }.assertOkJson().row_id client.postA("/accounts/merchant/transactions") { json(valid_req) { "request_uid" to requestUid } }.assertOkJson { assertEquals(id, it.row_id) } client.postA("/accounts/merchant/transactions") { json(valid_req) { "request_uid" to requestUid "amount" to "KUDOS:42" } }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) } // Check amount in payto_uri client.postA("/accounts/merchant/transactions") { json { "payto_uri" to "$exchangePayto?message=payout2&amount=KUDOS:1.05" } }.assertOkJson { client.getA("/accounts/merchant/transactions/${it.row_id}") .assertOkJson { tx -> assertEquals("payout2", tx.subject) assertEquals(TalerAmount("KUDOS:1.05"), tx.amount) } } // Check amount in payto_uri precedence client.postA("/accounts/merchant/transactions") { json { "payto_uri" to "$exchangePayto?message=payout3&amount=KUDOS:1.05" "amount" to "KUDOS:10.003" } }.assertOkJson { client.getA("/accounts/merchant/transactions/${it.row_id}") .assertOkJson { tx -> assertEquals("payout3", tx.subject) assertEquals(TalerAmount("KUDOS:1.05"), tx.amount) } } // Testing the wrong currency client.postA("/accounts/merchant/transactions") { json(valid_req) { "amount" to "EUR:3.3" } }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) // Surpassing the debt limit client.postA("/accounts/merchant/transactions") { json(valid_req) { "amount" to "KUDOS:555" } }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) // Missing message client.postA("/accounts/merchant/transactions") { json(valid_req) { "payto_uri" to "$exchangePayto" } }.assertBadRequest() // Unknown creditor client.postA("/accounts/merchant/transactions") { json(valid_req) { "payto_uri" to "$unknownPayto?message=payout" } }.assertConflict(TalerErrorCode.BANK_UNKNOWN_CREDITOR) // Transaction to self client.postA("/accounts/merchant/transactions") { json(valid_req) { "payto_uri" to "$merchantPayto?message=payout" } }.assertConflict(TalerErrorCode.BANK_SAME_ACCOUNT) // Transaction to admin val adminPayto = client.getA("/accounts/admin") .assertOkJson().payto_uri client.postA("/accounts/merchant/transactions") { json(valid_req) { "payto_uri" to "$adminPayto&message=payout" } }.assertConflict(TalerErrorCode.BANK_ADMIN_CREDITOR) // Init state assertBalance("merchant", "+KUDOS:0") assertBalance("customer", "+KUDOS:0") // Send 2 times 3 repeat(2) { tx("merchant", "KUDOS:3", "customer") } client.postA("/accounts/merchant/transactions") { json { "payto_uri" to "$customerPayto?message=payout2&amount=KUDOS:5" } }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) assertBalance("merchant", "-KUDOS:6") assertBalance("customer", "+KUDOS:6") // Send through debt tx("customer", "KUDOS:10", "merchant") assertBalance("merchant", "+KUDOS:4") assertBalance("customer", "-KUDOS:4") tx("merchant", "KUDOS:4", "customer") // Check bounce assertBalance("merchant", "+KUDOS:0") assertBalance("exchange", "+KUDOS:0") tx("merchant", "KUDOS:1", "exchange", "") // Bounce common to transaction tx("merchant", "KUDOS:1", "exchange", "Malformed") // Bounce malformed transaction val reservePub = EddsaPublicKey.rand() tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reservePub)) // Accept incoming tx("merchant", "KUDOS:1", "exchange", randIncomingSubject(reservePub)) // Bounce reserve_pub reuse assertBalance("merchant", "-KUDOS:1") assertBalance("exchange", "+KUDOS:1") // Check warn assertBalance("merchant", "-KUDOS:1") assertBalance("exchange", "+KUDOS:1") tx("exchange", "KUDOS:1", "merchant", "") // Warn common to transaction tx("exchange", "KUDOS:1", "merchant", "Malformed") // Warn malformed transaction val wtid = ShortHashCode.rand() val exchange = ExchangeUrl("http://exchange.example.com/") tx("exchange", "KUDOS:1", "merchant", randOutgoingSubject(wtid, exchange)) // Accept outgoing tx("exchange", "KUDOS:1", "merchant", randOutgoingSubject(wtid, exchange)) // Warn wtid reuse assertBalance("merchant", "+KUDOS:3") assertBalance("exchange", "-KUDOS:3") // Check 2fa fillTanInfo("merchant") assertBalance("merchant", "+KUDOS:3") assertBalance("customer", "+KUDOS:0") client.postA("/accounts/merchant/transactions") { json { "payto_uri" to "$customerPayto?message=tan+check&amount=KUDOS:1" } }.assertChallenge { _,_-> assertBalance("merchant", "+KUDOS:3") assertBalance("customer", "+KUDOS:0") }.assertOkJson { assertBalance("merchant", "+KUDOS:2") assertBalance("customer", "+KUDOS:1") } // Check 2fa idempotency val req = obj { "payto_uri" to "$customerPayto?message=tan+check&amount=KUDOS:1" "request_uid" to ShortHashCode.rand() } val id = client.postA("/accounts/merchant/transactions") { json(req) }.assertChallenge { _,_-> assertBalance("merchant", "+KUDOS:2") assertBalance("customer", "+KUDOS:1") }.assertOkJson { assertBalance("merchant", "+KUDOS:1") assertBalance("customer", "+KUDOS:2") }.row_id client.postA("/accounts/merchant/transactions") { json(req) }.assertOkJson { assertEquals(id, it.row_id) } client.postA("/accounts/merchant/transactions") { json(req) { "payto_uri" to "$customerPayto?message=tan+chec2k&amount=KUDOS:1" } }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) } } class CoreBankWithdrawalApiTest { // POST /accounts/USERNAME/withdrawals @Test fun create() = bankSetup { _ -> authRoutine(HttpMethod.Post, "/accounts/merchant/withdrawals") // Check OK client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:9.0" } }.assertOkJson { assertEquals("taler+http://withdraw/localhost:80/taler-integration/${it.withdrawal_id}", it.taler_withdraw_uri) } // Check exchange account client.postA("/accounts/exchange/withdrawals") { json { "amount" to "KUDOS:9.0" } }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE) // Check insufficient fund client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:90" } }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) } // GET /withdrawals/withdrawal_id @Test fun get() = bankSetup { _ -> val amount = TalerAmount("KUDOS:9.0") // Check OK client.postA("/accounts/merchant/withdrawals") { json { "amount" to amount} }.assertOkJson { client.get("/withdrawals/${it.withdrawal_id}") { pwAuth("merchant") }.assertOkJson { assertEquals(amount, it.amount) } } // Check polling statusRoutine("/withdrawals") { it.status } // Check bad UUID client.get("/withdrawals/chocolate").assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) // Check unknown client.get("/withdrawals/${UUID.randomUUID()}") .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } // POST /accounts/USERNAME/withdrawals/withdrawal_id/confirm @Test fun confirm() = bankSetup { _ -> authRoutine(HttpMethod.Post, "/accounts/merchant/withdrawals/42/confirm") // Check confirm created client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:1" } }.assertOkJson { val uuid = it.taler_withdraw_uri.split("/").last() // Check err client.postA("/accounts/merchant/withdrawals/$uuid/confirm") .assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE) } // Check confirm selected client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:1" } }.assertOkJson { val uuid = it.taler_withdraw_uri.split("/").last() withdrawalSelect(uuid) // Check OK client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent() // Check idempotence client.postA("/accounts/merchant/withdrawals/$uuid/confirm").assertNoContent() } // Check confirm aborted client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:1" } }.assertOkJson { val uuid = it.taler_withdraw_uri.split("/").last() withdrawalSelect(uuid) client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() // Check error client.postA("/accounts/merchant/withdrawals/$uuid/confirm") .assertConflict(TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT) } // Check balance insufficient client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:5" } }.assertOkJson { val uuid = it.taler_withdraw_uri.split("/").last() withdrawalSelect(uuid) // Send too much money tx("merchant", "KUDOS:5", "customer") client.postA("/accounts/merchant/withdrawals/$uuid/confirm") .assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) // Check can abort because not confirmed client.postA("/accounts/merchant/withdrawals/$uuid/abort").assertNoContent() } // Check confirm another user's operation client.postA("/accounts/customer/withdrawals") { json { "amount" to "KUDOS:1" } }.assertOkJson { val uuid = it.taler_withdraw_uri.split("/").last() withdrawalSelect(uuid) // Check error client.postA("/accounts/merchant/withdrawals/$uuid/confirm") .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } // Check bad UUID client.postA("/accounts/merchant/withdrawals/chocolate/confirm") .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) // Check unknown client.postA("/accounts/merchant/withdrawals/${UUID.randomUUID()}/confirm") .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) // Check 2fa fillTanInfo("merchant") assertBalance("merchant", "-KUDOS:6") client.postA("/accounts/merchant/withdrawals") { json { "amount" to "KUDOS:1" } }.assertOkJson { val uuid = it.taler_withdraw_uri.split("/").last() withdrawalSelect(uuid) client.postA("/accounts/merchant/withdrawals/$uuid/confirm") .assertChallenge { _,_-> assertBalance("merchant", "-KUDOS:6") }.assertNoContent() } } } class CoreBankCashoutApiTest { // POST /accounts/{USERNAME}/cashouts @Test fun create() = bankSetup { _ -> authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts") val req = obj { "request_uid" to ShortHashCode.rand() "amount_debit" to "KUDOS:1" "amount_credit" to convert("KUDOS:1") } // Missing info client.postA("/accounts/customer/cashouts") { json(req) }.assertConflict(TalerErrorCode.BANK_CONFIRM_INCOMPLETE) fillCashoutInfo("customer") // Check OK val id = client.postA("/accounts/customer/cashouts") { json(req) }.assertOkJson().cashout_id // Check idempotent client.postA("/accounts/customer/cashouts") { json(req) }.assertOkJson { assertEquals(id, it.cashout_id) } // Trigger conflict due to reused request_uid client.postA("/accounts/customer/cashouts") { json(req) { "amount_debit" to "KUDOS:2" "amount_credit" to convert("KUDOS:2") } }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) // Check exchange account client.postA("/accounts/exchange/cashouts") { json(req) }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_EXCHANGE) // Check insufficient fund client.postA("/accounts/customer/cashouts") { json(req) { "request_uid" to ShortHashCode.rand() "amount_debit" to "KUDOS:75" "amount_credit" to convert("KUDOS:75") } }.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT) // Check wrong conversion client.postA("/accounts/customer/cashouts") { json(req) { "amount_credit" to convert("KUDOS:2") } }.assertConflict(TalerErrorCode.BANK_BAD_CONVERSION) // Check wrong currency client.postA("/accounts/customer/cashouts") { json(req) { "amount_debit" to "EUR:1" } }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) client.postA("/accounts/customer/cashouts") { json(req) { "amount_credit" to "KUDOS:1" } }.assertBadRequest(TalerErrorCode.GENERIC_CURRENCY_MISMATCH) // Check 2fa fillTanInfo("customer") assertBalance("customer", "-KUDOS:1") client.postA("/accounts/customer/cashouts") { json(req) { "request_uid" to ShortHashCode.rand() } }.assertChallenge { _,_-> assertBalance("customer", "-KUDOS:1") }.assertOkJson { assertBalance("customer", "-KUDOS:2") } } // GET /accounts/{USERNAME}/cashouts/{CASHOUT_ID} @Test fun get() = bankSetup { _ -> authRoutine(HttpMethod.Get, "/accounts/merchant/cashouts/42") fillCashoutInfo("customer") val amountDebit = TalerAmount("KUDOS:1.5") val amountCredit = convert("KUDOS:1.5") val req = obj { "amount_debit" to amountDebit "amount_credit" to amountCredit } // Check confirm client.postA("/accounts/customer/cashouts") { json(req) { "request_uid" to ShortHashCode.rand() } }.assertOkJson { val id = it.cashout_id client.getA("/accounts/customer/cashouts/$id") .assertOkJson { assertEquals(CashoutStatus.confirmed, it.status) assertEquals(amountDebit, it.amount_debit) assertEquals(amountCredit, it.amount_credit) assertNull(it.tan_channel) assertNull(it.tan_info) } } // Check bad UUID client.getA("/accounts/customer/cashouts/chocolate") .assertBadRequest(TalerErrorCode.GENERIC_PARAMETER_MALFORMED) // Check unknown client.getA("/accounts/customer/cashouts/42") .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) // Check get another user's operation client.postA("/accounts/customer/cashouts") { json(req) { "request_uid" to ShortHashCode.rand() } }.assertOkJson { val id = it.cashout_id // Check error client.getA("/accounts/merchant/cashouts/$id") .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } } // GET /accounts/{USERNAME}/cashouts @Test fun history() = bankSetup { _ -> authRoutine(HttpMethod.Get, "/accounts/merchant/cashouts") historyRoutine( url = "/accounts/customer/cashouts", ids = { it.cashouts.map { it.cashout_id } }, registered = listOf({ cashout("KUDOS:0.1") }), polling = false ) } // GET /cashouts @Test fun globalHistory() = bankSetup { _ -> authRoutine(HttpMethod.Get, "/cashouts", requireAdmin = true) historyRoutine( url = "/cashouts", ids = { it.cashouts.map { it.cashout_id } }, registered = listOf({ cashout("KUDOS:0.1") }), polling = false, auth = "admin" ) } @Test fun notImplemented() = bankSetup("test_no_conversion.conf") { _ -> client.get("/accounts/customer/cashouts") .assertNotImplemented() } } class CoreBankTanApiTest { // POST /accounts/{USERNAME}/challenge/{challenge_id} @Test fun send() = bankSetup { _ -> authRoutine(HttpMethod.Post, "/accounts/merchant/challenge/42") suspend fun HttpResponse.expectChallenge(channel: TanChannel, info: String): HttpResponse { return assertChallenge { tanChannel, tanInfo -> assertEquals(channel, tanChannel) assertEquals(info, tanInfo) } } suspend fun HttpResponse.expectTransmission(channel: TanChannel, info: String) { this.assertOkJson { assertEquals(it.tan_channel, channel) assertEquals(it.tan_info, info) } } // Set up 2fa client.patchA("/accounts/merchant") { json { "contact_data" to obj { "phone" to "+99" "email" to "email@example.com" } "tan_channel" to "sms" } }.expectChallenge(TanChannel.sms, "+99") .assertNoContent() // Update 2fa settings - first 2FA challenge then new tan channel check client.patchA("/accounts/merchant") { json { // Info change "contact_data" to obj { "phone" to "+98" } } }.expectChallenge(TanChannel.sms, "+99") .expectChallenge(TanChannel.sms, "+98") .assertNoContent() client.patchA("/accounts/merchant") { json { // Channel change "tan_channel" to "email" } }.expectChallenge(TanChannel.sms, "+98") .expectChallenge(TanChannel.email, "email@example.com") .assertNoContent() client.patchA("/accounts/merchant") { json { // Both change "contact_data" to obj { "phone" to "+97" } "tan_channel" to "sms" } }.expectChallenge(TanChannel.email, "email@example.com") .expectChallenge(TanChannel.sms, "+97") .assertNoContent() // Disable 2fa client.patchA("/accounts/merchant") { json { "tan_channel" to null as String? } }.expectChallenge(TanChannel.sms, "+97") .assertNoContent() // Admin has no 2FA client.patch("/accounts/merchant") { pwAuth("admin") json { "contact_data" to obj { "phone" to "+99" } "tan_channel" to "sms" } }.assertNoContent() client.patch("/accounts/merchant") { pwAuth("admin") json { "tan_channel" to "email" } }.assertNoContent() client.patch("/accounts/merchant") { pwAuth("admin") json { "tan_channel" to null as String? } }.assertNoContent() // Check retry and invalidate client.patchA("/accounts/merchant") { json { "contact_data" to obj { "phone" to "+88" } "tan_channel" to "sms" } }.assertChallenge().assertNoContent() client.patchA("/accounts/merchant") { json { "is_public" to false } }.assertAcceptedJson { // Check ok client.postA("/accounts/merchant/challenge/${it.challenge_id}") .expectTransmission(TanChannel.sms, "+88") assertNotNull(tanCode("+88")) // Check retry client.postA("/accounts/merchant/challenge/${it.challenge_id}") .expectTransmission(TanChannel.sms, "+88") assertNull(tanCode("+88")) // Idempotent patch does nothing client.patchA("/accounts/merchant") { json { "contact_data" to obj { "phone" to "+88" } "tan_channel" to "sms" } } client.postA("/accounts/merchant/challenge/${it.challenge_id}") .expectTransmission(TanChannel.sms, "+88") assertNull(tanCode("+88")) // Change 2fa settings client.patchA("/accounts/merchant") { json { "tan_channel" to "email" } }.expectChallenge(TanChannel.sms, "+88") .expectChallenge(TanChannel.email, "email@example.com") .assertNoContent() // Check invalidated client.postA("/accounts/merchant/challenge/${it.challenge_id}") .expectTransmission(TanChannel.email, "email@example.com") assertNotNull(tanCode("email@example.com")) } // Unknown challenge client.postA("/accounts/merchant/challenge/42") .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } // POST /accounts/{USERNAME}/challenge/{challenge_id} @Test fun sendTanErr() = bankSetup("test_tan_err.conf") { _ -> // Check fail client.patch("/accounts/merchant") { pwAuth("admin") json { "contact_data" to obj { "phone" to "+1234" } "tan_channel" to "sms" } }.assertNoContent() client.patchA("/accounts/merchant") { json { "is_public" to false } }.assertAcceptedJson { client.postA("/accounts/merchant/challenge/${it.challenge_id}") .assertStatus(HttpStatusCode.BadGateway, TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED) } } // POST /accounts/{USERNAME}/challenge/{challenge_id}/confirm @Test fun confirm() = bankSetup { _ -> authRoutine(HttpMethod.Post, "/accounts/merchant/challenge/42/confirm") fillTanInfo("merchant") // Check simple case client.patchA("/accounts/merchant") { json { "is_public" to false } }.assertAcceptedJson { val id = it.challenge_id val info = client.postA("/accounts/merchant/challenge/$id") .assertOkJson().tan_info val code = tanCode(info) // Check bad TAN code client.postA("/accounts/merchant/challenge/$id/confirm") { json { "tan" to "nice-try" } }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_FAILED) // Check wrong account client.postA("/accounts/customer/challenge/$id/confirm") { json { "tan" to "nice-try" } }.assertNotFound(TalerErrorCode.BANK_CHALLENGE_NOT_FOUND) // Check OK client.postA("/accounts/merchant/challenge/$id/confirm") { json { "tan" to code } }.assertNoContent() // Check idempotence client.postA("/accounts/merchant/challenge/$id/confirm") { json { "tan" to code } }.assertNoContent() // Unknown challenge client.postA("/accounts/merchant/challenge/42/confirm") { json { "tan" to code } }.assertNotFound(TalerErrorCode.BANK_CHALLENGE_NOT_FOUND) } // Check invalidation client.patchA("/accounts/merchant") { json { "is_public" to true } }.assertAcceptedJson { val id = it.challenge_id val info = client.postA("/accounts/merchant/challenge/$id") .assertOkJson().tan_info // Check invalidated fillTanInfo("merchant") client.postA("/accounts/merchant/challenge/$id/confirm") { json { "tan" to tanCode(info) } }.assertConflict(TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED) val new = client.postA("/accounts/merchant/challenge/$id") .assertOkJson().tan_info val code = tanCode(new) // Idempotent patch does nothing client.patchA("/accounts/merchant") { json { "contact_data" to obj { "phone" to "+88" } "tan_channel" to "sms" } } client.postA("/accounts/merchant/challenge/$id/confirm") { json { "tan" to code } }.assertNoContent() // Solved challenge remain solved fillTanInfo("merchant") client.postA("/accounts/merchant/challenge/$id/confirm") { json { "tan" to code } }.assertNoContent() } } }