libeufin

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

commit 449d1bcf56972717f59d1ab3082f1adc28c341b4
parent 96f44f2a12d4e5c71d043a9bbc737ca20a6d4d0b
Author: Antoine A <>
Date:   Thu, 23 Nov 2023 00:11:28 +0000

bank and nexus integration draft

Diffstat:
MMakefile | 1+
Mbank/conf/test.conf | 6++++++
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 37+++++++++++++++++++++++++++++++++----
Mbank/src/test/kotlin/BankIntegrationApiTest.kt | 1+
Mbank/src/test/kotlin/CoreBankApiTest.kt | 1+
Mbank/src/test/kotlin/WireGatewayApiTest.kt | 1+
Mbank/src/test/kotlin/helpers.kt | 43-------------------------------------------
Mbank/src/test/kotlin/routines.kt | 1+
Mdatabase-versioning/libeufin-bank-procedures.sql | 5+++++
Adatabase-versioning/libeufin-conversion.sql | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aintegration/build.gradle | 44++++++++++++++++++++++++++++++++++++++++++++
Aintegration/test/IntegrationTest.kt | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msettings.gradle | 1+
Mutil/build.gradle | 4+++-
Autil/src/main/kotlin/Client.kt | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
15 files changed, 400 insertions(+), 48 deletions(-)

diff --git a/Makefile b/Makefile @@ -41,6 +41,7 @@ install-bank-files: install contrib/libeufin-bank.conf $(bank_config_dir)/ install contrib/currencies.conf $(bank_config_dir)/ install -D database-versioning/libeufin-bank*.sql -t $(bank_sql_dir) + install -D database-versioning/libeufin-conversion.sql -t $(bank_sql_dir) install -D database-versioning/versioning.sql -t $(bank_sql_dir) .PHONY: install-bank diff --git a/bank/conf/test.conf b/bank/conf/test.conf @@ -21,3 +21,9 @@ cashin_rounding_mode = nearest cashout_ratio = 1.25 cashout_fee = EUR:0.003 cashout_min_amount = KUDOS:0.1 + +[nexus-postgres] +CONFIG = postgres:///libeufincheck + +[libeufin-nexusdb-postgres] +SQL_DIR = $DATADIR/sql/ diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -45,6 +45,7 @@ import java.time.Duration import java.util.zip.DataFormatException import java.util.zip.Inflater import java.sql.SQLException +import java.io.File import kotlin.system.exitProcess import kotlinx.coroutines.* import kotlinx.serialization.ExperimentalSerializationApi @@ -56,9 +57,7 @@ import org.slf4j.LoggerFactory import org.slf4j.event.Level import org.postgresql.util.PSQLState import tech.libeufin.bank.AccountDAO.* -import tech.libeufin.util.getVersion -import tech.libeufin.util.initializeDatabaseTables -import tech.libeufin.util.resetDatabaseTables +import tech.libeufin.util.* // GLOBALS private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.Main") @@ -188,6 +187,7 @@ fun Application.corebankWebApp(db: Database, ctx: BankConfig) { ) } exception<SQLException> { call, cause -> + cause.printStackTrace() val err = when (cause.sqlState) { PSQLState.SERIALIZATION_FAILURE.state -> libeufinError( HttpStatusCode.InternalServerError, @@ -321,6 +321,35 @@ class BankDbInit : CliktCommand("Initialize the libeufin-bank database", name = } } +class ConversionSetupCmd : CliktCommand("Setup conversion support", name = "conversion-setup") { + private val configFile by option( + "--config", "-c", + help = "set the configuration file" + ) + + override fun run() { + val config = talerConfig(configFile) + val cfg = config.loadDbConfig() + val ctx = config.loadBankConfig(); + val db = Database(cfg.dbConnStr, ctx.regionalCurrency, ctx.fiatCurrency) + runBlocking { + logger.info("doing DB initialization, sqldir ${cfg.sqlDir}, dbConnStr ${cfg.dbConnStr}") + val sqlProcedures = File("${cfg.sqlDir}/libeufin-conversion.sql") + if (!sqlProcedures.exists()) { + logger.info("Missing libeufin-conversion.sql file") + exitProcess(1) + } + pgDataSource(cfg.dbConnStr).pgConnection().execSQLUpdate(sqlProcedures.readText()) + + // Load conversion config + ctx.conversionInfo?.run { + logger.info("loading conversion config in DB") + db.conversion.updateConfig(this) + } + } + } +} + class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") { private val configFile by option( "--config", "-c", @@ -438,7 +467,7 @@ class BankConfigCmd : CliktCommand("Dump the configuration", name = "config") { class LibeufinBankCommand : CliktCommand() { init { versionOption(getVersion()) - subcommands(ServeBank(), BankDbInit(), ChangePw(), BankConfigCmd()) + subcommands(ServeBank(), BankDbInit(), ConversionSetupCmd(), ChangePw(), BankConfigCmd()) } override fun run() = Unit diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt @@ -29,6 +29,7 @@ import kotlinx.serialization.json.* import net.taler.common.errorcodes.TalerErrorCode import org.junit.Test import tech.libeufin.bank.* +import tech.libeufin.util.* class BankIntegrationApiTest { // GET /taler-integration/config diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt @@ -34,6 +34,7 @@ import net.taler.common.errorcodes.TalerErrorCode import net.taler.wallet.crypto.Base32Crockford import org.junit.Test import tech.libeufin.bank.* +import tech.libeufin.util.* class CoreBankConfigTest { // GET /config diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt @@ -28,6 +28,7 @@ import kotlinx.serialization.json.* import net.taler.common.errorcodes.TalerErrorCode import org.junit.Test import tech.libeufin.bank.* +import tech.libeufin.util.* class WireGatewayApiTest { // Testing the POST /transfer call from the TWG API. diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -326,32 +326,6 @@ inline suspend fun <reified B> HttpResponse.assertHistoryIds(size: Int, ids: (B) /* ----- Body helper ----- */ -inline fun <reified B> HttpRequestBuilder.json(b: B, deflate: Boolean = false) { - val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), b); - contentType(ContentType.Application.Json) - if (deflate) { - headers.set("Content-Encoding", "deflate") - val bos = ByteArrayOutputStream() - val ios = DeflaterOutputStream(bos) - ios.write(json.toByteArray()) - ios.finish() - setBody(bos.toByteArray()) - } else { - setBody(json) - } -} - -inline fun HttpRequestBuilder.json( - from: JsonObject = JsonObject(emptyMap()), - deflate: Boolean = false, - builderAction: JsonBuilder.() -> Unit -) { - json(obj(from, builderAction), deflate) -} - -inline suspend fun <reified B> HttpResponse.json(): B = - Json.decodeFromString(kotlinx.serialization.serializer<B>(), bodyAsText()) - inline suspend fun <reified B> HttpResponse.assertOkJson(lambda: (B) -> Unit = {}): B { assertOk() val body = json<B>() @@ -398,23 +372,6 @@ fun HttpRequestBuilder.pwAuth(username: String? = null) { } -/* ----- Json DSL ----- */ - -inline fun obj(from: JsonObject = JsonObject(emptyMap()), builderAction: JsonBuilder.() -> Unit): JsonObject { - val builder = JsonBuilder(from) - builder.apply(builderAction) - return JsonObject(builder.content) -} - -class JsonBuilder(from: JsonObject) { - val content: MutableMap<String, JsonElement> = from.toMutableMap() - - infix inline fun <reified T> String.to(v: T) { - val json = Json.encodeToJsonElement(kotlinx.serialization.serializer<T>(), v); - content.put(this, json) - } -} - /* ----- Random data generation ----- */ fun randBytes(lenght: Int): ByteArray { diff --git a/bank/src/test/kotlin/routines.kt b/bank/src/test/kotlin/routines.kt @@ -18,6 +18,7 @@ */ import tech.libeufin.bank.* +import tech.libeufin.util.* import io.ktor.client.statement.HttpResponse import io.ktor.server.testing.ApplicationTestBuilder import io.ktor.client.request.* diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -973,6 +973,7 @@ CREATE OR REPLACE FUNCTION cashin( IN in_amount taler_amount, IN in_subject TEXT, -- Error status + OUT out_no_account BOOLEAN, OUT out_too_small BOOLEAN, OUT out_balance_insufficient BOOLEAN ) @@ -987,6 +988,10 @@ SELECT bank_account_id INTO wallet_account_id FROM bank_accounts WHERE internal_payto_uri = in_payto_uri; +IF NOT FOUND THEN + out_no_account = true; + RETURN; +END IF; -- Retrieve admin account id SELECT bank_account_id diff --git a/database-versioning/libeufin-conversion.sql b/database-versioning/libeufin-conversion.sql @@ -0,0 +1,78 @@ +BEGIN; +SET search_path TO libeufin_conversion; + +CREATE OR REPLACE FUNCTION cashout() +RETURNS trigger +LANGUAGE plpgsql AS $$ + DECLARE + now_date BIGINT; + payto_uri TEXT; + BEGIN + IF NEW.local_transaction IS NOT NULL THEN + SELECT transaction_date INTO now_date + FROM libeufin_bank.bank_account_transactions + WHERE bank_transaction_id = NEW.local_transaction; + SELECT cashout_payto INTO payto_uri + FROM libeufin_bank.bank_accounts + JOIN libeufin_bank.customers ON customer_id=owning_customer_id + WHERE bank_account_id=NEW.bank_account; + INSERT INTO libeufin_nexus.initiated_outgoing_transactions ( + amount + ,wire_transfer_subject + ,credit_payto_uri + ,initiation_time + ,request_uid + ) VALUES ( + ((NEW.amount_credit).val, (NEW.amount_credit).frac)::libeufin_nexus.taler_amount + ,NEW.subject + ,payto_uri + ,now_date + ,'TODO' -- How to generate this + ); + END IF; + RETURN NEW; + END; +$$; + +CREATE OR REPLACE TRIGGER cashout BEFORE INSERT OR UPDATE ON libeufin_bank.cashout_operations + FOR EACH ROW EXECUTE FUNCTION cashout(); + +CREATE OR REPLACE FUNCTION cashin() +RETURNS trigger +LANGUAGE plpgsql AS $$ + DECLARE + now_date BIGINT; + payto_uri TEXT; + local_amount libeufin_bank.taler_amount; + subject TEXT; + too_small BOOLEAN; + balance_insufficient BOOLEAN; + no_account BOOLEAN; + BEGIN + SELECT (amount).val, (amount).frac, wire_transfer_subject, execution_time, debit_payto_uri + INTO local_amount.val, local_amount.frac, subject, now_date, payto_uri + FROM libeufin_nexus.incoming_transactions + WHERE incoming_transaction_id = NEW.incoming_transaction_id; + SET search_path TO libeufin_bank; + SELECT out_too_small, out_balance_insufficient, out_no_account + INTO too_small, balance_insufficient, no_account + FROM libeufin_bank.cashin(now_date, payto_uri, local_amount, subject); + SET search_path TO libeufin_conversion; + + IF no_account THEN + RAISE EXCEPTION 'TODO soft error bounce: unknown account'; + END IF; + IF too_small THEN + RAISE EXCEPTION 'TODO soft error bounce: too small amount'; + END IF; + IF balance_insufficient THEN + RAISE EXCEPTION 'TODO hard error bounce'; + END IF; + RETURN NEW; + END; +$$; + +CREATE OR REPLACE TRIGGER cashin BEFORE INSERT ON libeufin_nexus.talerable_incoming_transactions + FOR EACH ROW EXECUTE FUNCTION cashin(); + +COMMIT; +\ No newline at end of file diff --git a/integration/build.gradle b/integration/build.gradle @@ -0,0 +1,43 @@ +plugins { + id("kotlin") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +compileKotlin.kotlinOptions.jvmTarget = "17" +compileTestKotlin.kotlinOptions.jvmTarget = "17" + +sourceSets.test.java.srcDirs = ["test"] + +dependencies { + // Core language libraries + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + + implementation(project(":util")) + implementation(project(":bank")) + implementation(project(":nexus")) + + implementation("org.postgresql:postgresql:42.6.0") + implementation("com.zaxxer:HikariCP:5.0.1") + implementation("com.github.ajalt.clikt:clikt:4.2.1") + + implementation("io.ktor:ktor-server-core:$ktor_version") + implementation("io.ktor:ktor-server-call-logging:$ktor_version") + implementation("io.ktor:ktor-server-cors:$ktor_version") + implementation("io.ktor:ktor-server-content-negotiation:$ktor_version") + implementation("io.ktor:ktor-server-status-pages:$ktor_version") + implementation("io.ktor:ktor-server-netty:$ktor_version") + implementation("io.ktor:ktor-server-test-host:$ktor_version") + implementation("io.ktor:ktor-client-core:$ktor_version") + implementation("io.ktor:ktor-client-cio:$ktor_version") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") + //implementation("com.github.ajalt.clikt:clikt.testing:4.2.1") + testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") + + // UNIX domain sockets support (used to connect to PostgreSQL) + implementation("com.kohlschutter.junixsocket:junixsocket-core:2.8.1") +} +\ No newline at end of file diff --git a/integration/test/IntegrationTest.kt b/integration/test/IntegrationTest.kt @@ -0,0 +1,142 @@ +/* + * 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.Database as BankDb +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.AccountDAO.* +import tech.libeufin.util.* +import java.io.File +import java.time.Instant +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) +} + +suspend fun HttpResponse.assertStatus(status: HttpStatusCode): HttpResponse { + assertEquals(status, this.status); + return this +} + +suspend fun HttpResponse.assertCreated(): HttpResponse + = assertStatus(HttpStatusCode.Created) +suspend fun HttpResponse.assertNoContent(): HttpResponse + = assertStatus(HttpStatusCode.NoContent) + +fun randBytes(lenght: Int): ByteArray { + val bytes = ByteArray(lenght) + kotlin.random.Random.nextBytes(bytes) + return bytes +} + +class IntegrationTest { + @Test + fun db() { + val nexusCmd = LibeufinNexusCommand() + nexusCmd.run("dbinit -c ../bank/conf/test.conf -r") + val bankCmd = LibeufinBankCommand(); + bankCmd.run("dbinit -c ../bank/conf/test.conf -r") + bankCmd.run("conversion-setup -c ../bank/conf/test.conf") + kotlin.concurrent.thread(isDaemon = true) { + bankCmd.run("serve -c ../bank/conf/test.conf") + } + runBlocking { + val client = HttpClient(CIO) { + install(HttpRequestRetry) { + maxRetries = 10 + constantDelay(200, 100) + } + } + val nexusDb = NexusDb("postgresql:///libeufincheck") + val userPayTo = IbanPayTo(genIbanPaytoUri()) + val fiatPayTo = IbanPayTo(genIbanPaytoUri()) + + // Create user + client.post("http://0.0.0.0:8080/accounts") { + json { + "username" to "customer" + "password" to "password" + "name" to "JohnSmith" + "internal_payto_uri" to userPayTo + "cashout_payto_uri" to fiatPayTo + "challenge_contact_data" to obj { + "phone" to "+99" + } + } + }.assertCreated() + + // Cashin + val reservePub = randBytes(32); + nexusDb.incomingTalerablePaymentCreate(IncomingPayment( + amount = NexusAmount(44, 0, "EUR"), + debitPaytoUri = userPayTo.canonical, + wireTransferSubject = "cashin test", + executionTime = Instant.now(), + bankTransferId = "entropic"), + reservePub) + val converted = client.get("http://0.0.0.0:8080/conversion-info/cashin-rate?amount_debit=EUR:44.0") + .assertOkJson<ConversionResponse>().amount_credit + client.get("http://0.0.0.0:8080/accounts/customer/transactions") { + basicAuth("customer", "password") + }.assertOkJson<BankAccountTransactionsResponse> { + val tx = it.transactions[0] + assertEquals(userPayTo.canonical, tx.creditor_payto_uri) + assertEquals("cashin test", tx.subject) + assertEquals(converted, tx.amount) + } + + // Cashout + val requestUid = randBytes(32); + val amount = BankAmount("KUDOS:25") + 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/settings.gradle b/settings.gradle @@ -2,3 +2,4 @@ rootProject.name = 'libeufin' include("bank") include("nexus") include("util") +include("integration") diff --git a/util/build.gradle b/util/build.gradle @@ -32,6 +32,8 @@ dependencies { implementation("io.ktor:ktor-server-test-host:$ktor_version") // Database helper implementation("org.postgresql:postgresql:42.6.0") + implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") - testImplementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") + implementation("org.jetbrains.kotlin:kotlin-test:$kotlin_version") + } \ No newline at end of file diff --git a/util/src/main/kotlin/Client.kt b/util/src/main/kotlin/Client.kt @@ -0,0 +1,81 @@ +/* + * 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/> + */ + +package tech.libeufin.util + +import io.ktor.http.* +import kotlinx.serialization.json.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import java.io.ByteArrayOutputStream +import java.util.zip.DeflaterOutputStream +import kotlin.test.assertEquals +import net.taler.common.errorcodes.TalerErrorCode + +/* ----- Json DSL ----- */ + +inline fun obj(from: JsonObject = JsonObject(emptyMap()), builderAction: JsonBuilder.() -> Unit): JsonObject { + val builder = JsonBuilder(from) + builder.apply(builderAction) + return JsonObject(builder.content) +} + +class JsonBuilder(from: JsonObject) { + val content: MutableMap<String, JsonElement> = from.toMutableMap() + + infix inline fun <reified T> String.to(v: T) { + val json = Json.encodeToJsonElement(kotlinx.serialization.serializer<T>(), v); + content.put(this, json) + } +} + +/* ----- Json body helper ----- */ + +inline fun <reified B> HttpRequestBuilder.json(b: B, deflate: Boolean = false) { + val json = Json.encodeToString(kotlinx.serialization.serializer<B>(), b); + contentType(ContentType.Application.Json) + if (deflate) { + headers.set("Content-Encoding", "deflate") + val bos = ByteArrayOutputStream() + val ios = DeflaterOutputStream(bos) + ios.write(json.toByteArray()) + ios.finish() + setBody(bos.toByteArray()) + } else { + setBody(json) + } +} + +inline fun HttpRequestBuilder.json( + from: JsonObject = JsonObject(emptyMap()), + deflate: Boolean = false, + builderAction: JsonBuilder.() -> Unit +) { + json(obj(from, builderAction), deflate) +} + +inline suspend fun <reified B> HttpResponse.json(): B = + Json.decodeFromString(kotlinx.serialization.serializer<B>(), bodyAsText()) + +inline suspend fun <reified B> HttpResponse.assertOkJson(lambda: (B) -> Unit = {}): B { + assertEquals(status, HttpStatusCode.OK) + val body = json<B>() + lambda(body) + return body +} +\ No newline at end of file