/*
* 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 org.junit.Test
import tech.libeufin.bank.*
import tech.libeufin.nexus.*
import tech.libeufin.nexus.db.Database as NexusDb
import tech.libeufin.bank.db.AccountDAO.*
import tech.libeufin.common.*
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 kotlin.io.path.*
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 server(lambda: () -> Unit) {
// Start the HTTP server in another thread
kotlin.concurrent.thread(isDaemon = true) {
lambda()
}
// 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() {
val flags = "-c conf/mini.conf -L DEBUG"
bankCmd.run("dbinit $flags -r")
bankCmd.run("passwd admin password $flags")
bankCmd.run("dbinit $flags") // Idempotent
server {
bankCmd.run("serve $flags")
}
setup { _ ->
// Check bank is running
client.get("http://0.0.0.0:8080/public-accounts").assertNoContent()
}
}
@Test
fun errors() {
val flags = "-c conf/integration.conf -L DEBUG"
nexusCmd.run("dbinit $flags -r")
bankCmd.run("dbinit $flags -r")
bankCmd.run("passwd admin password $flags")
suspend fun checkCount(db: NexusDb, nbIncoming: Int, nbBounce: Int, nbTalerable: Int) {
db.conn { 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.rand()
val fiatPayTo = IbanPayto.rand()
// Load conversion setup manually as the server would refuse to start without an exchange account
val sqlProcedures = Path("../database-versioning/libeufin-conversion-setup.sql")
db.conn {
it.execSQLUpdate(sqlProcedures.readText())
it.execSQLUpdate("SET search_path TO libeufin_nexus;")
}
val reservePub = EddsaPublicKey.rand()
val payment = IncomingPayment(
amount = TalerAmount("EUR:10"),
debitPaytoUri = userPayTo.toString(),
wireTransferSubject = "Error test $reservePub",
executionTime = Instant.now(),
bankId = "error"
)
assertException("ERROR: cashin failed: missing exchange account") {
ingestIncomingPayment(db, payment)
}
// Create exchange account
bankCmd.run("create-account $flags -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 $flags")
}
// 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.payment.registerTalerableIncoming(payment, reservePub)
}
// Allow admin debt
bankCmd.run("edit-account admin --debit_threshold KUDOS:100 $flags")
// Too small amount
checkCount(db, 0, 0, 0)
ingestIncomingPayment(db, payment.copy(
amount = TalerAmount("EUR:0.01"),
))
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 = TalerAmount("EUR:10"),
debitPaytoUri = userPayTo.toString(),
wireTransferSubject = "Success ${Base32Crockford32B.rand().encoded()}",
executionTime = Instant.now(),
bankId = "success"
))
checkCount(db, 2, 1, 1)
client.get("http://0.0.0.0:8080/accounts/exchange/transactions") {
basicAuth("exchange", "password")
}.assertOkJson()
// TODO check double insert cashin with different subject
}
}
@Test
fun conversion() {
val flags = "-c conf/integration.conf -L DEBUG"
nexusCmd.run("dbinit $flags -r")
bankCmd.run("dbinit $flags -r")
bankCmd.run("passwd admin password $flags")
bankCmd.run("edit-account admin --debit_threshold KUDOS:1000 $flags")
bankCmd.run("create-account $flags -u exchange -p password --name 'Mr Money' --exchange")
nexusCmd.run("dbinit $flags") // Idempotent
bankCmd.run("dbinit $flags") // Idempotent
server {
bankCmd.run("serve $flags")
}
setup { db ->
val userPayTo = IbanPayto.rand()
val fiatPayTo = IbanPayto.rand()
// 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()
// 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 = EddsaPublicKey.rand()
val amount = TalerAmount("EUR:${20+i}")
val subject = "cashin test $i: $reservePub"
nexusCmd.run("testing fake-incoming $flags --subject \"$subject\" --amount $amount $userPayTo")
val converted = client.get("http://0.0.0.0:8080/conversion-info/cashin-rate?amount_debit=EUR:${20 + i}")
.assertOkJson().amount_credit
client.get("http://0.0.0.0:8080/accounts/exchange/transactions") {
basicAuth("exchange", "password")
}.assertOkJson {
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 {
val tx = it.incoming_transactions.first()
assertEquals(converted, tx.amount)
assertEquals(reservePub, tx.reserve_pub)
}
}
// Cashout
repeat(3) { i ->
val requestUid = ShortHashCode.rand()
val amount = TalerAmount("KUDOS:${10+i}")
val convert = client.get("http://0.0.0.0:8080/conversion-info/cashout-rate?amount_debit=$amount")
.assertOkJson().amount_credit
client.post("http://0.0.0.0:8080/accounts/customer/cashouts") {
basicAuth("customer", "password")
json {
"request_uid" to requestUid
"amount_debit" to amount
"amount_credit" to convert
}
}.assertOkJson()
}
}
}
}