aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAntoine A <>2024-01-13 13:03:28 +0000
committerAntoine A <>2024-01-13 13:03:28 +0000
commitfe4c49c5444b6033e23bc31bebeb2407d61452c1 (patch)
tree6593aaf38158f9e8d8575c21b57802679acc3133
parent1de5b7203cbc8cd090223523259f11cdcf26e475 (diff)
downloadlibeufin-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.kt252
-rw-r--r--database-versioning/libeufin-conversion-setup.sql7
-rw-r--r--integration/build.gradle2
-rw-r--r--integration/conf/integration.conf1
-rw-r--r--integration/src/main/kotlin/Main.kt4
-rw-r--r--integration/src/main/test/IntegrationTest.kt187
-rw-r--r--integration/src/test/kotlin/IntegrationTest.kt335
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt4
-rw-r--r--nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt3
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(