/* * This file is part of LibEuFin. * Copyright (C) 2023-2024 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.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.testing.* import kotlinx.coroutines.runBlocking import tech.libeufin.bank.* import tech.libeufin.bank.db.AccountDAO.AccountCreationResult import tech.libeufin.bank.db.Database import tech.libeufin.common.* import tech.libeufin.common.db.dbInit import tech.libeufin.common.db.pgDataSource import java.nio.file.NoSuchFileException import kotlin.io.path.Path import kotlin.io.path.deleteExisting import kotlin.io.path.readText import kotlin.random.Random import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotNull /* ----- Setup ----- */ val merchantPayto = IbanPayto.rand() val exchangePayto = IbanPayto.rand() val customerPayto = IbanPayto.rand() val unknownPayto = IbanPayto.rand() var tmpPayTo = IbanPayto.rand() val paytos = mapOf( "merchant" to merchantPayto, "exchange" to exchangePayto, "customer" to customerPayto ) fun genTmpPayTo(): IbanPayto { tmpPayTo = IbanPayto.rand() return tmpPayTo } fun setup( conf: String = "test.conf", lambda: suspend (Database, BankConfig) -> Unit ) = runBlocking { val config = talerConfig(Path("conf/$conf")) val dbCfg = config.loadDbConfig() val ctx = config.loadBankConfig() pgDataSource(dbCfg.dbConnStr).run { dbInit(dbCfg, "libeufin-nexus", true) dbInit(dbCfg, "libeufin-bank", true) } Database(dbCfg, ctx.regionalCurrency, ctx.fiatCurrency).use { it.conn { conn -> val sqlProcedures = Path("${dbCfg.sqlDir}/libeufin-conversion-setup.sql") conn.execSQLUpdate(sqlProcedures.readText()) } lambda(it, ctx) } } fun bankSetup( conf: String = "test.conf", lambda: suspend ApplicationTestBuilder.(Database) -> Unit ) = setup(conf) { db, cfg -> // Creating the exchange and merchant accounts first. val bonus = TalerAmount("KUDOS:0") assertIs(db.account.create( login = "merchant", password = "merchant-password", name = "Merchant", internalPayto = merchantPayto, maxDebt = TalerAmount("KUDOS:10"), isTalerExchange = false, isPublic = false, bonus = bonus, checkPaytoIdempotent = false, email = null, phone = null, cashoutPayto = null, tanChannel = null, ctx = cfg.payto )) assertIs(db.account.create( login = "exchange", password = "exchange-password", name = "Exchange", internalPayto = exchangePayto, maxDebt = TalerAmount("KUDOS:10"), isTalerExchange = true, isPublic = false, bonus = bonus, checkPaytoIdempotent = false, email = null, phone = null, cashoutPayto = null, tanChannel = null, ctx = cfg.payto )) assertIs(db.account.create( login = "customer", password = "customer-password", name = "Customer", internalPayto = customerPayto, maxDebt = TalerAmount("KUDOS:10"), isTalerExchange = false, isPublic = false, bonus = bonus, checkPaytoIdempotent = false, email = null, phone = null, cashoutPayto = null, tanChannel = null, ctx = cfg.payto )) // Create admin account assertIs(createAdminAccount(db, cfg, "admin-password")) testApplication { application { corebankWebApp(db, cfg) } if (cfg.allowConversion) { // Set conversion rates client.post("/conversion-info/conversion-rate") { pwAuth("admin") json { "cashin_ratio" to "0.8" "cashin_fee" to "KUDOS:0.02" "cashin_tiny_amount" to "KUDOS:0.01" "cashin_rounding_mode" to "nearest" "cashin_min_amount" to "EUR:0" "cashout_ratio" to "1.25" "cashout_fee" to "EUR:0.003" "cashout_tiny_amount" to "EUR:0.00000001" "cashout_rounding_mode" to "zero" "cashout_min_amount" to "KUDOS:0.1" } }.assertNoContent() } lambda(db) } } fun dbSetup(lambda: suspend (Database) -> Unit) = setup { db, _ -> lambda(db) } /* ----- Common actions ----- */ /** Set [account] debit threshold to [maxDebt] amount */ suspend fun ApplicationTestBuilder.setMaxDebt(account: String, maxDebt: String) { client.patch("/accounts/$account") { pwAuth("admin") json { "debit_threshold" to maxDebt } }.assertNoContent() } /** Check [account] balance is [amount], [amount] is prefixed with + for credit and - for debit */ suspend fun ApplicationTestBuilder.assertBalance(account: String, amount: String) { client.get("/accounts/$account") { pwAuth("admin") }.assertOkJson { val balance = it.balance val fmt = "${if (balance.credit_debit_indicator == CreditDebitInfo.debit) '-' else '+'}${balance.amount}" assertEquals(amount, fmt, "For $account") } } /** Check [account] tan channel and info */ suspend fun ApplicationTestBuilder.tanInfo(account: String): Pair { val res = client.getA("/accounts/$account").assertOkJson() val channel: TanChannel? = res.tan_channel return Pair(channel, when (channel) { TanChannel.sms -> res.contact_data!!.phone.get() TanChannel.email -> res.contact_data!!.email.get() null -> null }) } /** Perform a bank transaction of [amount] [from] account [to] account with [subject} */ suspend fun ApplicationTestBuilder.tx(from: String, amount: String, to: String, subject: String = "payout"): Long { return client.postA("/accounts/$from/transactions") { json { "payto_uri" to "${paytos[to] ?: tmpPayTo}?message=${subject.encodeURLQueryComponent()}&amount=$amount" } }.maybeChallenge().assertOkJson().row_id } /** Perform a taler outgoing transaction of [amount] from exchange to merchant */ suspend fun ApplicationTestBuilder.transfer(amount: String) { client.postA("/accounts/exchange/taler-wire-gateway/transfer") { json { "request_uid" to HashCode.rand() "amount" to TalerAmount(amount) "exchange_base_url" to "http://exchange.example.com/" "wtid" to ShortHashCode.rand() "credit_account" to merchantPayto } }.assertOk() } /** Perform a taler incoming transaction of [amount] from merchant to exchange */ suspend fun ApplicationTestBuilder.addIncoming(amount: String) { client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") { pwAuth("admin") json { "amount" to TalerAmount(amount) "reserve_pub" to EddsaPublicKey.rand() "debit_account" to merchantPayto } }.assertOk() } /** Perform a cashout operation of [amount] from customer */ suspend fun ApplicationTestBuilder.cashout(amount: String) { val res = client.postA("/accounts/customer/cashouts") { json { "request_uid" to ShortHashCode.rand() "amount_debit" to amount "amount_credit" to convert(amount) } } if (res.status == HttpStatusCode.Conflict) { // Retry with cashout info fillCashoutInfo("customer") client.postA("/accounts/customer/cashouts") { json { "request_uid" to ShortHashCode.rand() "amount_debit" to amount "amount_credit" to convert(amount) } } } else { res }.assertOk() } /** Perform a whithrawal operation of [amount] from customer */ suspend fun ApplicationTestBuilder.withdrawal(amount: String) { client.postA("/accounts/merchant/withdrawals") { json { "amount" to amount } }.assertOkJson { val uuid = it.taler_withdraw_uri.split("/").last() withdrawalSelect(uuid) client.postA("/accounts/merchant/withdrawals/${uuid}/confirm") .assertNoContent() } } suspend fun ApplicationTestBuilder.fillCashoutInfo(account: String) { client.patch("/accounts/$account") { pwAuth("admin") json { "cashout_payto_uri" to unknownPayto "contact_data" to obj { "phone" to "+99" } } }.assertNoContent() } suspend fun ApplicationTestBuilder.fillTanInfo(account: String) { client.patch("/accounts/$account") { pwAuth("admin") json { "contact_data" to obj { "phone" to "+${Random.nextInt(0, 10000)}" } "tan_channel" to "sms" } }.assertNoContent() } suspend fun ApplicationTestBuilder.withdrawalSelect(uuid: String) { client.post("/taler-integration/withdrawal-operation/$uuid") { json { "reserve_pub" to EddsaPublicKey.rand() "selected_exchange" to exchangePayto } }.assertOk() } suspend fun ApplicationTestBuilder.convert(amount: String): TalerAmount { return client.get("/conversion-info/cashout-rate?amount_debit=$amount") .assertOkJson().amount_credit } suspend fun tanCode(info: String): String? { try { val file = Path("/tmp/tan-$info.txt") val code = file.readText().split(" ", limit=2).first() file.deleteExisting() return code } catch (e: Exception) { if (e is NoSuchFileException) return null throw e } } /* ----- Assert ----- */ suspend fun HttpResponse.maybeChallenge(): HttpResponse { return if (this.status == HttpStatusCode.Accepted) { this.assertChallenge() } else { this } } suspend fun HttpResponse.assertChallenge( check: suspend (TanChannel, String) -> Unit = { _, _ -> } ): HttpResponse { val id = assertAcceptedJson().challenge_id val username = call.request.url.pathSegments[2] val res = call.client.postA("/accounts/$username/challenge/$id").assertOkJson() check(res.tan_channel, res.tan_info) val code = tanCode(res.tan_info) assertNotNull(code) call.client.postA("/accounts/$username/challenge/$id/confirm") { json { "tan" to code } }.assertNoContent() return call.client.request(this.call.request.url) { pwAuth(username) method = call.request.method headers["X-Challenge-Id"] = "$id" } } suspend fun assertTime(min: Int, max: Int, lambda: suspend () -> Unit) { val start = System.currentTimeMillis() lambda() val end = System.currentTimeMillis() val time = end - start assert(time >= min) { "Expected to last at least $min ms, lasted $time" } assert(time <= max) { "Expected to last at most $max ms, lasted $time" } } fun assertException(msg: String, lambda: () -> Unit) { try { lambda() throw Exception("Expected failure") } catch (e: Exception) { assert(e.message!!.startsWith(msg)) { "${e.message}" } } } suspend inline fun HttpResponse.assertHistoryIds(size: Int, ids: (B) -> List): B { assertOk() val body = json() val history = ids(body) val params = PageParams.extract(call.request.url.parameters) // testing the size is like expected. assertEquals(size, history.size, "bad history length: $history") if (params.delta < 0) { // testing that the first id is at most the 'start' query param. assert(history[0] <= params.start) { "bad history start: $params $history" } // testing that the id decreases. if (history.size > 1) assert(history.windowed(2).all { (a, b) -> a > b }) { "bad history order: $history" } } else { // testing that the first id is at least the 'start' query param. assert(history[0] >= params.start) { "bad history start: $params $history" } // testing that the id increases. if (history.size > 1) assert(history.windowed(2).all { (a, b) -> a < b }) { "bad history order: $history" } } return body } /* ----- Body helper ----- */ suspend inline fun HttpResponse.assertOkJson(lambda: (B) -> Unit = {}): B { assertOk() val body = json() lambda(body) return body } suspend inline fun HttpResponse.assertAcceptedJson(lambda: (B) -> Unit = {}): B { assertAccepted() val body = json() lambda(body) return body } /* ----- Auth ----- */ /** Auto auth get request */ suspend inline fun HttpClient.getA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse { return get(url) { pwAuth() builder(this) } } /** Auto auth post request */ suspend inline fun HttpClient.postA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse { return post(url) { pwAuth() builder(this) } } /** Auto auth patch request */ suspend inline fun HttpClient.patchA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse { return patch(url) { pwAuth() builder(this) } } /** Auto auth delete request */ suspend inline fun HttpClient.deleteA(url: String, builder: HttpRequestBuilder.() -> Unit = {}): HttpResponse { return delete(url) { pwAuth() builder(this) } } fun HttpRequestBuilder.pwAuth(username: String? = null) { if (username != null) { basicAuth("$username", "$username-password") } else if (url.pathSegments.contains("admin")) { basicAuth("admin", "admin-password") } else if (url.pathSegments[1] == "accounts") { // Extract username from path val login = url.pathSegments[2] basicAuth("$login", "$login-password") } } /* ----- Random data generation ----- */ fun randBase32Crockford(length: Int) = Base32Crockford.encode(ByteArray(length).rand()) fun randIncomingSubject(reservePub: EddsaPublicKey): String = "$reservePub" fun randOutgoingSubject(wtid: ShortHashCode, url: ExchangeUrl): String = "$wtid $url"