diff options
author | Antoine A <> | 2024-01-13 13:03:28 +0000 |
---|---|---|
committer | Antoine A <> | 2024-01-13 13:03:28 +0000 |
commit | fe4c49c5444b6033e23bc31bebeb2407d61452c1 (patch) | |
tree | 6593aaf38158f9e8d8575c21b57802679acc3133 | |
parent | 1de5b7203cbc8cd090223523259f11cdcf26e475 (diff) | |
download | libeufin-fe4c49c5444b6033e23bc31bebeb2407d61452c1.tar.gz libeufin-fe4c49c5444b6033e23bc31bebeb2407d61452c1.tar.bz2 libeufin-fe4c49c5444b6033e23bc31bebeb2407d61452c1.zip |
Bounce cashin with too small amounts
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/Main.kt | 252 | ||||
-rw-r--r-- | database-versioning/libeufin-conversion-setup.sql | 7 | ||||
-rw-r--r-- | integration/build.gradle | 2 | ||||
-rw-r--r-- | integration/conf/integration.conf | 1 | ||||
-rw-r--r-- | integration/src/main/kotlin/Main.kt | 4 | ||||
-rw-r--r-- | integration/src/main/test/IntegrationTest.kt | 187 | ||||
-rw-r--r-- | integration/src/test/kotlin/IntegrationTest.kt | 335 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt | 4 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt | 3 |
9 files changed, 478 insertions, 317 deletions
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt index 50d47429..3824a9a8 100644 --- a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt +++ b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -59,6 +59,8 @@ import tech.libeufin.bank.db.* import tech.libeufin.util.* private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.Main") +// Dirty local variable to stop the server in test TODO remove this ugly hack +var engine: ApplicationEngine? = null /** * This plugin check for body lenght limit and inflates the requests that have "Content-Encoding: deflate" @@ -236,23 +238,24 @@ class BankDbInit : CliktCommand("Initialize the libeufin-bank database", name = val config = talerConfig(common.config) val cfg = config.loadDbConfig() val ctx = config.loadBankConfig(); - val db = Database(cfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency) - runBlocking { - db.conn { conn -> - if (requestReset) { - resetDatabaseTables(conn, cfg, sqlFilePrefix = "libeufin-bank") + Database(cfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { db -> + runBlocking { + db.conn { conn -> + if (requestReset) { + resetDatabaseTables(conn, cfg, sqlFilePrefix = "libeufin-bank") + } + initializeDatabaseTables(conn, cfg, sqlFilePrefix = "libeufin-bank") + } + // Create admin account if missing + val res = maybeCreateAdminAccount(db, ctx) // logs provided by the helper + when (res) { + AccountCreationResult.BonusBalanceInsufficient -> {} + AccountCreationResult.LoginReuse -> {} + AccountCreationResult.PayToReuse -> + throw Exception("Failed to create admin's account") + AccountCreationResult.Success -> + logger.info("Admin's account created") } - initializeDatabaseTables(conn, cfg, sqlFilePrefix = "libeufin-bank") - } - // Create admin account if missing - val res = maybeCreateAdminAccount(db, ctx) // logs provided by the helper - when (res) { - AccountCreationResult.BonusBalanceInsufficient -> {} - AccountCreationResult.LoginReuse -> {} - AccountCreationResult.PayToReuse -> - throw Exception("Failed to create admin's account") - AccountCreationResult.Success -> - logger.info("Admin's account created") } } } @@ -266,54 +269,56 @@ class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") val ctx = cfg.loadBankConfig() val dbCfg = cfg.loadDbConfig() val serverCfg = cfg.loadServerConfig() - val db = Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency) - runBlocking { - if (ctx.allowConversion) { - logger.info("Ensure exchange account exists") - val info = db.account.bankInfo("exchange") - if (info == null) { - throw Exception("Exchange account missing: an exchange account named 'exchange' is required for conversion to be enabled") - } else if (!info.isTalerExchange) { - throw Exception("Account is not an exchange: an exchange account named 'exchange' is required for conversion to be enabled") - } - logger.info("Ensure conversion is enabled") - val sqlProcedures = File("${dbCfg.sqlDir}/libeufin-conversion-setup.sql") - if (!sqlProcedures.exists()) { - throw Exception("Missing libeufin-conversion-setup.sql file") - } - db.conn { it.execSQLUpdate(sqlProcedures.readText()) } - } else { - logger.info("Ensure conversion is disabled") - val sqlProcedures = File("${dbCfg.sqlDir}/libeufin-conversion-drop.sql") - if (!sqlProcedures.exists()) { - throw Exception("Missing libeufin-conversion-drop.sql file") + Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { db -> + runBlocking { + if (ctx.allowConversion) { + logger.info("Ensure exchange account exists") + val info = db.account.bankInfo("exchange") + if (info == null) { + throw Exception("Exchange account missing: an exchange account named 'exchange' is required for conversion to be enabled") + } else if (!info.isTalerExchange) { + throw Exception("Account is not an exchange: an exchange account named 'exchange' is required for conversion to be enabled") + } + logger.info("Ensure conversion is enabled") + val sqlProcedures = File("${dbCfg.sqlDir}/libeufin-conversion-setup.sql") + if (!sqlProcedures.exists()) { + throw Exception("Missing libeufin-conversion-setup.sql file") + } + db.conn { it.execSQLUpdate(sqlProcedures.readText()) } + } else { + logger.info("Ensure conversion is disabled") + val sqlProcedures = File("${dbCfg.sqlDir}/libeufin-conversion-drop.sql") + if (!sqlProcedures.exists()) { + throw Exception("Missing libeufin-conversion-drop.sql file") + } + db.conn { it.execSQLUpdate(sqlProcedures.readText()) } + // Remove conversion info from the database ? } - db.conn { it.execSQLUpdate(sqlProcedures.readText()) } - // Remove conversion info from the database ? } - } - - val env = applicationEngineEnvironment { - connector { - when (serverCfg) { - is ServerConfig.Tcp -> { - port = serverCfg.port + + val env = applicationEngineEnvironment { + connector { + when (serverCfg) { + is ServerConfig.Tcp -> { + port = serverCfg.port + } + is ServerConfig.Unix -> + throw Exception("Can only serve libeufin-bank via TCP") } - is ServerConfig.Unix -> - throw Exception("Can only serve libeufin-bank via TCP") } + module { corebankWebApp(db, ctx) } } - module { corebankWebApp(db, ctx) } - } - val engine = embeddedServer(Netty, env) - when (serverCfg) { - is ServerConfig.Tcp -> { - logger.info("Server listening on http://localhost:${serverCfg.port}") + val local = embeddedServer(Netty, env) + engine = local + when (serverCfg) { + is ServerConfig.Tcp -> { + logger.info("Server listening on http://localhost:${serverCfg.port}") + } + is ServerConfig.Unix -> + throw Exception("Can only serve libeufin-bank via TCP") } - is ServerConfig.Unix -> - throw Exception("Can only serve libeufin-bank via TCP") + local.start(wait = true) } - engine.start(wait = true) } } @@ -329,15 +334,16 @@ class ChangePw : CliktCommand("Change account password", name = "passwd") { val cfg = talerConfig(common.config) val ctx = cfg.loadBankConfig() val dbCfg = cfg.loadDbConfig() - val db = Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency) - runBlocking { - val res = db.account.reconfigPassword(username, password, null) - when (res) { - AccountPatchAuthResult.UnknownAccount -> - throw Exception("Password change for '$username' account failed: unknown account") - AccountPatchAuthResult.OldPasswordMismatch -> { /* Can never happen */ } - AccountPatchAuthResult.Success -> - logger.info("Password change for '$username' account succeeded") + Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { db -> + runBlocking { + val res = db.account.reconfigPassword(username, password, null) + when (res) { + AccountPatchAuthResult.UnknownAccount -> + throw Exception("Password change for '$username' account failed: unknown account") + AccountPatchAuthResult.OldPasswordMismatch -> { /* Can never happen */ } + AccountPatchAuthResult.Success -> + logger.info("Password change for '$username' account succeeded") + } } } } @@ -372,31 +378,32 @@ class EditAccount : CliktCommand( val cfg = talerConfig(common.config) val ctx = cfg.loadBankConfig() val dbCfg = cfg.loadDbConfig() - val db = Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency) - runBlocking { - val req = AccountReconfiguration( - name = name, - is_taler_exchange = exchange, - is_public = is_public, - contact_data = ChallengeContactData( - // PATCH semantic, if not given do not change, if empty remove - email = if (email == null) Option.None else Option.Some(if (email != "") email else null), - phone = if (phone == null) Option.None else Option.Some(if (phone != "") phone else null), - ), - cashout_payto_uri = Option.Some(cashout_payto_uri), - debit_threshold = debit_threshold - ) - when (patchAccount(db, ctx, req, username, true)) { - AccountPatchResult.Success -> - logger.info("Account '$username' edited") - AccountPatchResult.UnknownAccount -> - throw Exception("Account '$username' not found") - AccountPatchResult.NonAdminName, - AccountPatchResult.NonAdminCashout, - AccountPatchResult.NonAdminDebtLimit, - AccountPatchResult.NonAdminContact -> { - // Unreachable as we edit account as admin - } + Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { db -> + runBlocking { + val req = AccountReconfiguration( + name = name, + is_taler_exchange = exchange, + is_public = is_public, + contact_data = ChallengeContactData( + // PATCH semantic, if not given do not change, if empty remove + email = if (email == null) Option.None else Option.Some(if (email != "") email else null), + phone = if (phone == null) Option.None else Option.Some(if (phone != "") phone else null), + ), + cashout_payto_uri = Option.Some(cashout_payto_uri), + debit_threshold = debit_threshold + ) + when (patchAccount(db, ctx, req, username, true)) { + AccountPatchResult.Success -> + logger.info("Account '$username' edited") + AccountPatchResult.UnknownAccount -> + throw Exception("Account '$username' not found") + AccountPatchResult.NonAdminName, + AccountPatchResult.NonAdminCashout, + AccountPatchResult.NonAdminDebtLimit, + AccountPatchResult.NonAdminContact -> { + // Unreachable as we edit account as admin + } + } } } } @@ -447,38 +454,39 @@ class CreateAccount : CliktCommand( val cfg = talerConfig(common.config) val ctx = cfg.loadBankConfig() val dbCfg = cfg.loadDbConfig() - val db = Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency) - runBlocking { - val req = json ?: options?.run { - RegisterAccountRequest( - username = username, - password = password, - name = name, - is_public = is_public, - is_taler_exchange = exchange, - contact_data = ChallengeContactData( - email = Option.Some(email), - phone = Option.Some(phone), - ), - cashout_payto_uri = cashout_payto_uri, - internal_payto_uri = internal_payto_uri, - payto_uri = payto_uri, - debit_threshold = debit_threshold - ) - } - req?.let { - val (result, internalPayto) = createAccount(db, ctx, req, true); - when (result) { - AccountCreationResult.BonusBalanceInsufficient -> - throw Exception("Insufficient admin funds to grant bonus") - AccountCreationResult.LoginReuse -> - throw Exception("Account username reuse '${req.username}'") - AccountCreationResult.PayToReuse -> - throw Exception("Bank internalPayToUri reuse '${internalPayto.canonical}'") - AccountCreationResult.Success -> - logger.info("Account '${req.username}' created") + Database(dbCfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency).use { db -> + runBlocking { + val req = json ?: options?.run { + RegisterAccountRequest( + username = username, + password = password, + name = name, + is_public = is_public, + is_taler_exchange = exchange, + contact_data = ChallengeContactData( + email = Option.Some(email), + phone = Option.Some(phone), + ), + cashout_payto_uri = cashout_payto_uri, + internal_payto_uri = internal_payto_uri, + payto_uri = payto_uri, + debit_threshold = debit_threshold + ) + } + req?.let { + val (result, internalPayto) = createAccount(db, ctx, req, true); + when (result) { + AccountCreationResult.BonusBalanceInsufficient -> + throw Exception("Insufficient admin funds to grant bonus") + AccountCreationResult.LoginReuse -> + throw Exception("Account username reuse '${req.username}'") + AccountCreationResult.PayToReuse -> + throw Exception("Bank internalPayToUri reuse '${internalPayto.canonical}'") + AccountCreationResult.Success -> + logger.info("Account '${req.username}' created") + } + println(internalPayto) } - println(internalPayto) } } } diff --git a/database-versioning/libeufin-conversion-setup.sql b/database-versioning/libeufin-conversion-setup.sql index 0bf1e506..3d170e11 100644 --- a/database-versioning/libeufin-conversion-setup.sql +++ b/database-versioning/libeufin-conversion-setup.sql @@ -60,9 +60,14 @@ LANGUAGE plpgsql AS $$ FROM libeufin_bank.cashin(now_date, NEW.reserve_public_key, local_amount, subject); SET search_path TO libeufin_nexus; + -- Bounce on soft failures IF too_small THEN - RAISE EXCEPTION 'cashin currency conversion failed: too small amount'; + -- TODO bounce fees ? + PERFORM bounce_incoming(NEW.incoming_transaction_id, ((local_amount).val, (local_amount).frac)::taler_amount, now_date); + RETURN NULL; END IF; + + -- Error on hard failures IF no_config THEN RAISE EXCEPTION 'cashin currency conversion failed: missing conversion rates'; END IF; diff --git a/integration/build.gradle b/integration/build.gradle index 4ac5bede..7c366e4c 100644 --- a/integration/build.gradle +++ b/integration/build.gradle @@ -22,6 +22,8 @@ dependencies { implementation("com.github.ajalt.clikt:clikt:$clikt_version") + implementation("org.postgresql:postgresql:$postgres_version") + implementation("io.ktor:ktor-server-test-host:$ktor_version") implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") implementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") diff --git a/integration/conf/integration.conf b/integration/conf/integration.conf index 83537c68..39056996 100644 --- a/integration/conf/integration.conf +++ b/integration/conf/integration.conf @@ -6,7 +6,6 @@ allow_conversion = YES FIAT_CURRENCY = EUR tan_sms = libeufin-tan-file.sh tan_email = libeufin-tan-fail.sh -PORT = 8090 [libeufin-bankdb-postgres] CONFIG = postgresql:///libeufincheck diff --git a/integration/src/main/kotlin/Main.kt b/integration/src/main/kotlin/Main.kt index 46dec5ef..6d823ffd 100644 --- a/integration/src/main/kotlin/Main.kt +++ b/integration/src/main/kotlin/Main.kt @@ -105,14 +105,14 @@ class Cli : CliktCommand("Run integration tests on banks provider") { if (!hasClientKeys) { step("Test INI order") - ask("Got to https://testplattform.postfinance.ch/corporates/user/settings/ebics and click on 'Reset EBICS user'.\nPress Enter when done>") + ask("Got to https://isotest.postfinance.ch/corporates/user/settings/ebics and click on 'Reset EBICS user'.\nPress Enter when done>") nexusCmd.test("ebics-setup -c $conf") .assertErr("ebics-setup should failed the first time") } if (!hasBankKeys) { step("Test HIA order") - ask("Got to https://testplattform.postfinance.ch/corporates/user/settings/ebics and click on 'Activate EBICS user'.\nPress Enter when done>") + ask("Got to https://isotest.postfinance.ch/corporates/user/settings/ebics and click on 'Activate EBICS user'.\nPress Enter when done>") nexusCmd.test("ebics-setup --auto-accept-keys -c $conf") .assertOk("ebics-setup should succeed the second time") } diff --git a/integration/src/main/test/IntegrationTest.kt b/integration/src/main/test/IntegrationTest.kt deleted file mode 100644 index e5766a61..00000000 --- a/integration/src/main/test/IntegrationTest.kt +++ /dev/null @@ -1,187 +0,0 @@ -/* - * 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 - * <http://www.gnu.org/licenses/> - */ - -import org.junit.Test -import tech.libeufin.bank.* -import tech.libeufin.bank.TalerAmount as BankAmount -import tech.libeufin.nexus.* -import tech.libeufin.nexus.Database as NexusDb -import tech.libeufin.nexus.TalerAmount as NexusAmount -import tech.libeufin.bank.db.AccountDAO.* -import tech.libeufin.util.* -import java.io.File -import java.time.Instant -import java.util.Arrays -import kotlinx.coroutines.runBlocking -import com.github.ajalt.clikt.testing.test -import com.github.ajalt.clikt.core.CliktCommand -import kotlin.test.* -import io.ktor.client.* -import io.ktor.client.engine.cio.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.HttpStatusCode - -fun CliktCommand.run(cmd: String) { - val result = test(cmd) - if (result.statusCode != 0) - throw Exception(result.output) - println(result.output) -} - -fun HttpResponse.assertNoContent() { - assertEquals(HttpStatusCode.NoContent, this.status) -} - -fun randBytes(lenght: Int): ByteArray { - val bytes = ByteArray(lenght) - kotlin.random.Random.nextBytes(bytes) - return bytes -} - -class IntegrationTest { - val nexusCmd = LibeufinNexusCommand() - val bankCmd = LibeufinBankCommand(); - val client = HttpClient(CIO) { - install(HttpRequestRetry) { - maxRetries = 10 - constantDelay(200, 100) - } - } - - @Test - fun mini() { - bankCmd.run("dbinit -c conf/mini.conf -r") - bankCmd.run("passwd admin password -c conf/mini.conf") - bankCmd.run("dbinit -c conf/mini.conf") // Indempotent - kotlin.concurrent.thread(isDaemon = true) { - bankCmd.run("serve -c conf/mini.conf") - } - - runBlocking { - // Check bank is running - client.get("http://0.0.0.0:8080/public-accounts").assertNoContent() - } - } - - @Test - fun conversion() { - nexusCmd.run("dbinit -c conf/integration.conf -r") - bankCmd.run("dbinit -c conf/integration.conf -r") - bankCmd.run("passwd admin password -c conf/integration.conf") - bankCmd.run("edit-account admin --debit_threshold KUDOS:1000 -c conf/integration.conf") - bankCmd.run("create-account -c conf/integration.conf -u exchange -p password --name 'Mr Money' --exchange") - nexusCmd.run("dbinit -c conf/integration.conf") // Idempotent - bankCmd.run("dbinit -c conf/integration.conf") // Idempotent - kotlin.concurrent.thread(isDaemon = true) { - bankCmd.run("serve -c conf/integration.conf") - } - - runBlocking { - val nexusDb = NexusDb("postgresql:///libeufincheck") - val userPayTo = IbanPayTo(genIbanPaytoUri()) - val fiatPayTo = IbanPayTo(genIbanPaytoUri()) - - // Create user - client.post("http://0.0.0.0:8090/accounts") { - basicAuth("admin", "password") - json { - "username" to "customer" - "password" to "password" - "name" to "JohnSmith" - "internal_payto_uri" to userPayTo - "cashout_payto_uri" to fiatPayTo - "debit_threshold" to "KUDOS:100" - "contact_data" to obj { - "phone" to "+99" - } - } - }.assertOkJson<RegisterAccountResponse>() - - // Set conversion rates - client.post("http://0.0.0.0:8090/conversion-info/conversion-rate") { - basicAuth("admin", "password") - 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() - - // Cashin - repeat(3) { i -> - val reservePub = randBytes(32); - val amount = NexusAmount(20L + i, 0, "EUR") - nexusDb.incomingTalerablePaymentCreate(IncomingPayment( - amount = amount, - debitPaytoUri = userPayTo.canonical, - wireTransferSubject = "cashin test $i", - executionTime = Instant.now(), - bankTransferId = "entropic"), - reservePub) - val converted = client.get("http://0.0.0.0:8090/conversion-info/cashin-rate?amount_debit=EUR:${20 + i}") - .assertOkJson<ConversionResponse>().amount_credit - client.get("http://0.0.0.0:8090/accounts/exchange/transactions") { - basicAuth("exchange", "password") - }.assertOkJson<BankAccountTransactionsResponse> { - val tx = it.transactions.first() - assertEquals("cashin test $i", tx.subject) - assertEquals(converted, tx.amount) - } - client.get("http://0.0.0.0:8090/accounts/exchange/taler-wire-gateway/history/incoming") { - basicAuth("exchange", "password") - }.assertOkJson<IncomingHistory> { - val tx = it.incoming_transactions.first() - assertEquals(converted, tx.amount) - assert(Arrays.equals(reservePub, tx.reserve_pub.raw)) - } - } - - // Cashout - repeat(3) { i -> - val requestUid = randBytes(32); - val amount = BankAmount("KUDOS:${10+i}") - val convert = client.get("http://0.0.0.0:8090/conversion-info/cashout-rate?amount_debit=$amount") - .assertOkJson<ConversionResponse>().amount_credit; - client.post("http://0.0.0.0:8090/accounts/customer/cashouts") { - basicAuth("customer", "password") - json { - "request_uid" to ShortHashCode(requestUid) - "amount_debit" to amount - "amount_credit" to convert - } - }.assertOkJson<CashoutPending> { - val code = File("/tmp/tan-+99.txt").readText() - client.post("http://0.0.0.0:8090/accounts/customer/cashouts/${it.cashout_id}/confirm") { - basicAuth("customer", "password") - json { "tan" to code } - }.assertNoContent() - } - } - } - } -} diff --git a/integration/src/test/kotlin/IntegrationTest.kt b/integration/src/test/kotlin/IntegrationTest.kt new file mode 100644 index 00000000..cc1ac2ab --- /dev/null +++ b/integration/src/test/kotlin/IntegrationTest.kt @@ -0,0 +1,335 @@ +/* + * 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 + * <http://www.gnu.org/licenses/> + */ + +import org.junit.Test +import net.taler.wallet.crypto.Base32Crockford +import tech.libeufin.bank.* +import tech.libeufin.bank.TalerAmount as BankAmount +import tech.libeufin.nexus.* +import tech.libeufin.nexus.Database as NexusDb +import tech.libeufin.nexus.TalerAmount as NexusAmount +import tech.libeufin.bank.db.AccountDAO.* +import tech.libeufin.util.* +import java.io.File +import java.time.Instant +import java.util.Arrays +import java.sql.SQLException +import kotlinx.coroutines.runBlocking +import com.github.ajalt.clikt.testing.test +import com.github.ajalt.clikt.core.CliktCommand +import org.postgresql.jdbc.PgConnection +import kotlin.test.* +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.HttpStatusCode + +fun CliktCommand.run(cmd: String) { + val result = test(cmd) + if (result.statusCode != 0) + throw Exception(result.output) + println(result.output) +} + +fun HttpResponse.assertNoContent() { + assertEquals(HttpStatusCode.NoContent, this.status) +} + +fun randBytes(lenght: Int): ByteArray { + val bytes = ByteArray(lenght) + kotlin.random.Random.nextBytes(bytes) + return bytes +} + +fun server(lambda: () -> Unit) { + // Start the HTTP server in another thread + kotlin.concurrent.thread(isDaemon = true) { + lambda() + println("STOP SERVER") + } + // Wait for the HTTP server to be up + runBlocking { + HttpClient(CIO) { + install(HttpRequestRetry) { + maxRetries = 10 + constantDelay(200, 100) + } + }.get("http://0.0.0.0:8080/config") + } + +} + +fun setup(lambda: suspend (NexusDb) -> Unit) { + try { + runBlocking { + NexusDb("postgresql:///libeufincheck").use { + lambda(it) + } + } + } finally { + engine?.stop(0, 0) // Stop http server if started + } +} + +inline fun assertException(msg: String, lambda: () -> Unit) { + try { + lambda() + throw Exception("Expected failure: $msg") + } catch (e: Exception) { + assert(e.message!!.startsWith(msg)) { "${e.message}" } + } +} + +class IntegrationTest { + val nexusCmd = LibeufinNexusCommand() + val bankCmd = LibeufinBankCommand(); + val client = HttpClient(CIO) + + @Test + fun mini() { + bankCmd.run("dbinit -c conf/mini.conf -r") + bankCmd.run("passwd admin password -c conf/mini.conf") + bankCmd.run("dbinit -c conf/mini.conf") // Indempotent + + server { + bankCmd.run("serve -c conf/mini.conf") + } + + setup { _ -> + // Check bank is running + client.get("http://0.0.0.0:8080/public-accounts").assertNoContent() + } + } + + @Test + fun errors() { + nexusCmd.run("dbinit -c conf/integration.conf -r") + bankCmd.run("dbinit -c conf/integration.conf -r") + bankCmd.run("passwd admin password -c conf/integration.conf") + + suspend fun checkCount(db: NexusDb, nbIncoming: Int, nbBounce: Int, nbTalerable: Int) { + db.runConn { conn -> + conn.prepareStatement("SELECT count(*) FROM incoming_transactions").oneOrNull { + assertEquals(nbIncoming, it.getInt(1)) + } + conn.prepareStatement("SELECT count(*) FROM bounced_transactions").oneOrNull { + assertEquals(nbBounce, it.getInt(1)) + } + conn.prepareStatement("SELECT count(*) FROM talerable_incoming_transactions").oneOrNull { + assertEquals(nbTalerable, it.getInt(1)) + } + } + } + + setup { db -> + val userPayTo = IbanPayTo(genIbanPaytoUri()) + val fiatPayTo = IbanPayTo(genIbanPaytoUri()) + + // Load conversion setup manually as the server would refuse to start without an exchange account + val sqlProcedures = File("../database-versioning/libeufin-conversion-setup.sql") + db.runConn { + it.execSQLUpdate(sqlProcedures.readText()) + it.execSQLUpdate("SET search_path TO libeufin_nexus;") + } + + val reservePub = randBytes(32) + val payment = IncomingPayment( + amount = NexusAmount(10, 0, "EUR"), + debitPaytoUri = userPayTo.canonical, + wireTransferSubject = "Error test ${Base32Crockford.encode(reservePub)}", + executionTime = Instant.now(), + bankTransferId = "error" + ) + + assertException("ERROR: cashin failed: missing exchange account") { + ingestIncomingPayment(db, payment) + } + + // Create exchange account + bankCmd.run("create-account -c conf/integration.conf -u exchange -p password --name 'Mr Money' --exchange") + + assertException("ERROR: cashin currency conversion failed: missing conversion rates") { + ingestIncomingPayment(db, payment) + } + + // Start server + server { + bankCmd.run("serve -c conf/integration.conf") + } + + // Set conversion rates + client.post("http://0.0.0.0:8080/conversion-info/conversion-rate") { + basicAuth("admin", "password") + 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() + + assertException("ERROR: cashin failed: admin balance insufficient") { + db.registerTalerableIncoming(payment, reservePub) + } + + // Allow admin debt + bankCmd.run("edit-account admin --debit_threshold KUDOS:100 -c conf/integration.conf") + + // Too small amount + checkCount(db, 0, 0, 0) + ingestIncomingPayment(db, payment.copy( + amount = NexusAmount(0, 10, "EUR"), + )) + checkCount(db, 1, 1, 0) + client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { + basicAuth("exchange", "password") + }.assertNoContent() + + // Check success + ingestIncomingPayment(db, IncomingPayment( + amount = NexusAmount(10, 0, "EUR"), + debitPaytoUri = userPayTo.canonical, + wireTransferSubject = "Success ${Base32Crockford.encode(randBytes(32))}", + executionTime = Instant.now(), + bankTransferId = "success" + )) + checkCount(db, 2, 1, 1) + client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { + basicAuth("exchange", "password") + }.assertOkJson<BankAccountTransactionsResponse>() + + // TODO check double insert cashin with different subject + } + } + + @Test + fun conversion() { + nexusCmd.run("dbinit -c conf/integration.conf -r") + bankCmd.run("dbinit -c conf/integration.conf -r") + bankCmd.run("passwd admin password -c conf/integration.conf") + bankCmd.run("edit-account admin --debit_threshold KUDOS:1000 -c conf/integration.conf") + bankCmd.run("create-account -c conf/integration.conf -u exchange -p password --name 'Mr Money' --exchange") + nexusCmd.run("dbinit -c conf/integration.conf") // Idempotent + bankCmd.run("dbinit -c conf/integration.conf") // Idempotent + + server { + bankCmd.run("serve -c conf/integration.conf") + } + + setup { db -> + val userPayTo = IbanPayTo(genIbanPaytoUri()) + val fiatPayTo = IbanPayTo(genIbanPaytoUri()) + + // Create user + client.post("http://0.0.0.0:8080/accounts") { + basicAuth("admin", "password") + json { + "username" to "customer" + "password" to "password" + "name" to "JohnSmith" + "internal_payto_uri" to userPayTo + "cashout_payto_uri" to fiatPayTo + "debit_threshold" to "KUDOS:100" + "contact_data" to obj { + "phone" to "+99" + } + } + }.assertOkJson<RegisterAccountResponse>() + + // Set conversion rates + client.post("http://0.0.0.0:8080/conversion-info/conversion-rate") { + basicAuth("admin", "password") + 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() + + // Cashin + repeat(3) { i -> + val reservePub = randBytes(32); + val amount = NexusAmount(20L + i, 0, "EUR") + val subject = "cashin test $i: ${Base32Crockford.encode(reservePub)}" + ingestIncomingPayment(db, + IncomingPayment( + amount = amount, + debitPaytoUri = userPayTo.canonical, + wireTransferSubject = subject, + executionTime = Instant.now(), + bankTransferId = Base32Crockford.encode(reservePub) + ) + ) + val converted = client.get("http://0.0.0.0:8080/conversion-info/cashin-rate?amount_debit=EUR:${20 + i}") + .assertOkJson<ConversionResponse>().amount_credit + client.get("http://0.0.0.0:8080/accounts/exchange/transactions") { + basicAuth("exchange", "password") + }.assertOkJson<BankAccountTransactionsResponse> { + val tx = it.transactions.first() + assertEquals(subject, tx.subject) + assertEquals(converted, tx.amount) + } + client.get("http://0.0.0.0:8080/accounts/exchange/taler-wire-gateway/history/incoming") { + basicAuth("exchange", "password") + }.assertOkJson<IncomingHistory> { + val tx = it.incoming_transactions.first() + assertEquals(converted, tx.amount) + assert(Arrays.equals(reservePub, tx.reserve_pub.raw)) + } + } + + // Cashout + repeat(3) { i -> + val requestUid = randBytes(32); + val amount = BankAmount("KUDOS:${10+i}") + val convert = client.get("http://0.0.0.0:8080/conversion-info/cashout-rate?amount_debit=$amount") + .assertOkJson<ConversionResponse>().amount_credit; + client.post("http://0.0.0.0:8080/accounts/customer/cashouts") { + basicAuth("customer", "password") + json { + "request_uid" to ShortHashCode(requestUid) + "amount_debit" to amount + "amount_credit" to convert + } + }.assertOkJson<CashoutPending> { + val code = File("/tmp/tan-+99.txt").readText() + client.post("http://0.0.0.0:8080/accounts/customer/cashouts/${it.cashout_id}/confirm") { + basicAuth("customer", "password") + json { "tan" to code } + }.assertNoContent() + } + } + } + } +} diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt index ba7278c4..d87555fb 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -269,7 +269,7 @@ private suspend fun getTalerReservePub( * @param db database handle. * @param payment payment to (maybe) ingest. */ -private suspend fun ingestOutgoingPayment( +suspend fun ingestOutgoingPayment( db: Database, payment: OutgoingPayment ) { @@ -293,7 +293,7 @@ private suspend fun ingestOutgoingPayment( * @param currency fiat currency of the watched bank account. * @param payment payment to (maybe) ingest. */ -private suspend fun ingestIncomingPayment( +suspend fun ingestIncomingPayment( db: Database, payment: IncomingPayment ) { diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt index de6d6639..a7ddfed3 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt @@ -161,8 +161,7 @@ private suspend fun submitInitiatedPayment( ) maybeLog( maybeLogDir, - xml, - initiatedPayment.requestUid + xml ) try { submitPain001( |