summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAntoine A <>2023-10-16 10:48:56 +0000
committerAntoine A <>2023-10-16 10:48:56 +0000
commite900f224cb9c8e5046b8bbb9db46f96cb40c3b9c (patch)
tree06a9128a12d1788a917d49ed576d89246b5524df
parentdb76884dee5897197ef0f3a0d8f72c58ea7e7723 (diff)
downloadlibeufin-e900f224cb9c8e5046b8bbb9db46f96cb40c3b9c.tar.gz
libeufin-e900f224cb9c8e5046b8bbb9db46f96cb40c3b9c.tar.bz2
libeufin-e900f224cb9c8e5046b8bbb9db46f96cb40c3b9c.zip
Cleanup and improve tests, fix procedures.sql
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt (renamed from bank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt)4
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt (renamed from bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt)7
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/Database.kt45
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/Main.kt6
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt46
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt (renamed from bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt)2
-rw-r--r--bank/src/test/kotlin/BankIntegrationApiTest.kt145
-rw-r--r--bank/src/test/kotlin/TalerApiTest.kt751
-rw-r--r--bank/src/test/kotlin/WireGatewayApiTest.kt529
-rw-r--r--bank/src/test/kotlin/helpers.kt54
-rw-r--r--database-versioning/procedures.sql4
11 files changed, 760 insertions, 833 deletions
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt
index 7a28618e..f607317e 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt
@@ -27,7 +27,7 @@ import io.ktor.server.response.*
import io.ktor.server.routing.*
import net.taler.common.errorcodes.TalerErrorCode
-fun Routing.talerIntegrationHandlers(db: Database, ctx: BankApplicationContext) {
+fun Routing.bankIntegrationApi(db: Database, ctx: BankApplicationContext) {
get("/taler-integration/config") {
val internalCurrency: String = ctx.currency
call.respond(TalerIntegrationConfigResponse(
@@ -55,7 +55,7 @@ fun Routing.talerIntegrationHandlers(db: Database, ctx: BankApplicationContext)
selection_done = op.selectionDone,
transfer_done = op.confirmationDone,
amount = op.amount,
- sender_wire = relatedBankAccount.internalPaytoUri.stripped,
+ sender_wire = relatedBankAccount.internalPaytoUri.canonical,
suggested_exchange = suggestedExchange,
confirm_transfer_url = confirmUrl
)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
index 6cf0d96d..3526e8c3 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt
@@ -14,6 +14,7 @@ import java.time.Duration
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.*
+import kotlin.random.Random
private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.accountsMgmtHandlers")
@@ -22,7 +23,7 @@ private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.account
* create, update, delete, show bank accounts. No histories
* and wire transfers should belong here.
*/
-fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) {
+fun Routing.accountsMgmtApi(db: Database, ctx: BankApplicationContext) {
// TOKEN ENDPOINTS
delete("/accounts/{USERNAME}/token") {
@@ -72,7 +73,7 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) {
)
}
val tokenBytes = ByteArray(32).apply {
- Random().nextBytes(this)
+ Random.nextBytes(this)
}
val tokenDuration: Duration = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION
@@ -187,7 +188,7 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) {
CryptoUtil.checkpw(req.password, maybeCustomerExists.passwordHash) &&
maybeHasBankAccount.isPublic == req.is_public &&
maybeHasBankAccount.isTalerExchange == req.is_taler_exchange &&
- maybeHasBankAccount.internalPaytoUri.stripped == internalPayto.stripped
+ maybeHasBankAccount.internalPaytoUri.canonical == internalPayto.canonical
if (isIdentic) {
call.respond(HttpStatusCode.Created)
return@post
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
index c3e97102..a7fbbb42 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
@@ -391,11 +391,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
creationTime = it.getLong("creation_time").microsToJavaInstant() ?: throw faultyTimestampByBank(),
expirationTime = it.getLong("expiration_time").microsToJavaInstant() ?: throw faultyDurationByClient(),
bankCustomer = it.getLong("bank_customer"),
- scope = when (it.getString("scope")) {
- TokenScope.readwrite.name -> TokenScope.readwrite
- TokenScope.readonly.name -> TokenScope.readonly
- else -> throw internalServerError("Wrong token scope found in the database: $this")
- },
+ scope = TokenScope.valueOf(it.getString("scope")),
isRefreshable = it.getBoolean("is_refreshable")
)
}
@@ -575,7 +571,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
(?, ?, ?, ?, (?, ?)::taler_amount)
RETURNING bank_account_id;
""")
- stmt.setString(1, bankAccount.internalPaytoUri.stripped)
+ stmt.setString(1, bankAccount.internalPaytoUri.canonical)
stmt.setLong(2, bankAccount.owningCustomerId)
stmt.setBoolean(3, bankAccount.isPublic)
stmt.setBoolean(4, bankAccount.isTalerExchange)
@@ -701,7 +697,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
FROM bank_accounts
WHERE internal_payto_uri=?
""")
- stmt.setString(1, internalPayto.stripped)
+ stmt.setString(1, internalPayto.canonical)
stmt.oneOrNull {
BankAccount(
@@ -863,13 +859,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
),
accountServicerReference = it.getString("account_servicer_reference"),
endToEndId = it.getString("end_to_end_id"),
- direction = it.getString("direction").run {
- when(this) {
- "credit" -> TransactionDirection.credit
- "debit" -> TransactionDirection.debit
- else -> throw internalServerError("Wrong direction in transaction: $this")
- }
- },
+ direction = TransactionDirection.valueOf(it.getString("direction")),
bankAccountId = it.getLong("bank_account_id"),
paymentInformationId = it.getString("payment_information_id"),
subject = it.getString("subject"),
@@ -989,13 +979,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
getCurrency()
),
subject = it.getString("subject"),
- direction = it.getString("direction").run {
- when(this) {
- "credit" -> TransactionDirection.credit
- "debit" -> TransactionDirection.debit
- else -> throw internalServerError("Wrong direction in transaction: $this")
- }
- }
+ direction = TransactionDirection.valueOf(it.getString("direction"))
)
}
}
@@ -1117,11 +1101,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
),
accountServicerReference = it.getString("account_servicer_reference"),
endToEndId = it.getString("end_to_end_id"),
- direction = when (it.getString("direction")) {
- "credit" -> TransactionDirection.credit
- "debit" -> TransactionDirection.debit
- else -> throw internalServerError("Wrong direction in transaction: $this")
- },
+ direction = TransactionDirection.valueOf(it.getString("direction")),
bankAccountId = it.getLong("bank_account_id"),
paymentInformationId = it.getString("payment_information_id"),
subject = it.getString("subject"),
@@ -1217,7 +1197,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
WHERE withdrawal_uuid=?
"""
)
- stmt.setString(1, exchangePayto.stripped)
+ stmt.setString(1, exchangePayto.canonical)
stmt.setString(2, reservePub)
stmt.setObject(3, opUuid)
stmt.executeUpdateViolation()
@@ -1427,12 +1407,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
getCurrency()
),
subject = it.getString("subject"),
- tanChannel = when(it.getString("tan_channel")) {
- "sms" -> TanChannel.sms
- "email" -> TanChannel.email
- "file" -> TanChannel.file
- else -> throw internalServerError("TAN channel $this unsupported")
- },
+ tanChannel = TanChannel.valueOf(it.getString("tan_channel")),
tanCode = it.getString("tan_code"),
localTransaction = it.getLong("local_transaction"),
tanConfirmationTime = when (val timestamp = it.getLong("tan_confirmation_time")) {
@@ -1516,7 +1491,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
stmt.setLong(4, req.amount.value)
stmt.setInt(5, req.amount.frac)
stmt.setString(6, req.exchange_base_url.url)
- stmt.setString(7, req.credit_account.stripped)
+ stmt.setString(7, req.credit_account.canonical)
stmt.setString(8, username)
stmt.setLong(9, timestamp.toDbMicros() ?: throw faultyTimestampByBank())
stmt.setString(10, acctSvcrRef)
@@ -1601,7 +1576,7 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
stmt.setString(2, subject)
stmt.setLong(3, req.amount.value)
stmt.setInt(4, req.amount.frac)
- stmt.setString(5, req.debit_account.stripped)
+ stmt.setString(5, req.debit_account.canonical)
stmt.setString(6, username)
stmt.setLong(7, timestamp.toDbMicros() ?: throw faultyTimestampByBank())
stmt.setString(8, acctSvcrRef)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
index 60eae30f..5e21826a 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt
@@ -259,9 +259,9 @@ fun Application.corebankWebApp(db: Database, ctx: BankApplicationContext) {
call.respond(Config(ctx.currencySpecification))
return@get
}
- this.accountsMgmtHandlers(db, ctx)
- this.talerIntegrationHandlers(db, ctx)
- this.talerWireGatewayHandlers(db, ctx)
+ this.accountsMgmtApi(db, ctx)
+ this.bankIntegrationApi(db, ctx)
+ this.wireGatewayApi(db, ctx)
}
}
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
index 2c5a996a..c7fb82c7 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt
@@ -384,50 +384,26 @@ class ExchangeUrl {
@Serializable(with = IbanPayTo.Serializer::class)
class IbanPayTo {
val parsed: URI
- val stripped: String
- // represent query param "sender-name" or "receiver-name".
- val receiverName: String?
- val iban: String
- val bic: String?
- // Typically, a wire transfer's subject.
+ val canonical: String
+ val amount: TalerAmount?
val message: String?
- val amount: String?
+ val receiverName: String?
constructor(raw: String) {
parsed = URI(raw)
require(parsed.scheme == "payto") { "expect a payto URI" }
require(parsed.host == "iban") { "expect a IBAN payto URI" }
+
val splitPath = parsed.path.split("/").filter { it.isNotEmpty() }
require(splitPath.size < 3 && splitPath.isNotEmpty()) { "too many path segments" }
- val parts = if (splitPath.size == 1) {
- Pair(splitPath[0], null)
- } else Pair(splitPath[1], splitPath[0])
- // TODO normalize IBAN & BIC ?
- iban = parts.first.uppercase()
- bic = parts.second?.uppercase()
- stripped = "payto://iban/$iban"
-
- val params: List<Pair<String, String>>? = if (parsed.query != null) {
- val queryString: List<String> = parsed.query.split("&")
- queryString.map {
- val split = it.split("=");
- require(split.size == 2) { "parameter '$it' was malformed" }
- Pair(split[0], split[1])
- }
- } else null
+ val iban = (if (splitPath.size == 1) splitPath[0] else splitPath[1]).replace("-", "").uppercase()
+ // TODO normalize && check IBAN ?
+ canonical = "payto://iban/$iban"
- // Return the value of query string parameter 'name', or null if not found.
- // 'params' is the list of key-value elements of all the query parameters found in the URI.
- fun getQueryParamOrNull(name: String): String? {
- if (params == null) return null
- return params.firstNotNullOfOrNull { pair ->
- URLDecoder.decode(pair.second, Charsets.UTF_8).takeIf { pair.first == name }
- }
- }
-
- amount = getQueryParamOrNull("amount")
- message = getQueryParamOrNull("message")
- receiverName = getQueryParamOrNull("receiver-name")
+ val params = (parsed.query ?: "").parseUrlEncodedParameters();
+ amount = params["amount"]?.run { TalerAmount(this) }
+ message = params["message"]
+ receiverName = params["receiver-name"]
}
internal object Serializer : KSerializer<IbanPayTo> {
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt
index 653ef732..9b0f6d64 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt
@@ -36,7 +36,7 @@ import kotlin.math.abs
private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus")
-fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) {
+fun Routing.wireGatewayApi(db: Database, ctx: BankApplicationContext) {
/** Authenticate and check access rights */
suspend fun ApplicationCall.authCheck(scope: TokenScope, withAdmin: Boolean): String {
val authCustomer = authenticateBankRequest(db, scope) ?: throw unauthorized("Bad login")
diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt
new file mode 100644
index 00000000..a4b36575
--- /dev/null
+++ b/bank/src/test/kotlin/BankIntegrationApiTest.kt
@@ -0,0 +1,145 @@
+import io.ktor.client.plugins.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.client.HttpClient
+import io.ktor.http.*
+import io.ktor.server.testing.*
+import kotlinx.serialization.json.*
+import kotlinx.coroutines.*
+import net.taler.wallet.crypto.Base32Crockford
+import org.junit.Test
+import tech.libeufin.bank.*
+import tech.libeufin.util.CryptoUtil
+import tech.libeufin.util.stripIbanPayto
+import java.util.*
+import java.time.Instant
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import randHashCode
+
+class BankIntegrationApiTest {
+ // Selecting withdrawal details from the Integration API endpoint.
+ @Test
+ fun intSelect() = bankSetup { db ->
+ val uuid = UUID.randomUUID()
+ // insert new.
+ assert(db.talerWithdrawalCreate(
+ opUUID = uuid,
+ walletBankAccount = 1L,
+ amount = TalerAmount(1, 0, "KUDOS")
+ ))
+
+ val r = client.post("/taler-integration/withdrawal-operation/${uuid}") {
+ jsonBody(BankWithdrawalOperationPostRequest(
+ reserve_pub = "RESERVE-FOO",
+ selected_exchange = IbanPayTo("payto://iban/ABC123")
+ ))
+ }.assertOk()
+ println(r.bodyAsText())
+ }
+
+ // Showing withdrawal details from the Integrtion API endpoint.
+ @Test
+ fun intGet() = bankSetup { db ->
+ val uuid = UUID.randomUUID()
+ // insert new.
+ assert(db.talerWithdrawalCreate(
+ opUUID = uuid,
+ walletBankAccount = 1L,
+ amount = TalerAmount(1, 0, "KUDOS")
+ ))
+
+ val r = client.get("/taler-integration/withdrawal-operation/${uuid}").assertOk()
+ println(r.bodyAsText())
+ }
+
+ // Testing withdrawal abort
+ @Test
+ fun withdrawalAbort() = bankSetup { db ->
+ val uuid = UUID.randomUUID()
+ // insert new.
+ assert(db.talerWithdrawalCreate(
+ opUUID = uuid,
+ walletBankAccount = 1L,
+ amount = TalerAmount(1, 0, "KUDOS")
+ ))
+ val op = db.talerWithdrawalGet(uuid)
+ assert(op?.aborted == false)
+ assert(db.talerWithdrawalSetDetails(uuid, IbanPayTo("payto://iban/exchange-payto"), "reserve_pub"))
+
+ client.post("/withdrawals/${uuid}/abort") {
+ basicAuth("merchant", "merchant-password")
+ }.assertOk()
+
+ val opAbo = db.talerWithdrawalGet(uuid)
+ assert(opAbo?.aborted == true && opAbo.selectionDone == true)
+ }
+
+ // Testing withdrawal creation
+ @Test
+ fun withdrawalCreation() = bankSetup { _ ->
+ // Creating the withdrawal as if the SPA did it.
+ val r = client.post("/accounts/merchant/withdrawals") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(BankAccountCreateWithdrawalRequest(TalerAmount(value = 9, frac = 0, currency = "KUDOS")))
+ }.assertOk()
+ val opId = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(r.bodyAsText())
+ // Getting the withdrawal from the bank. Throws (failing the test) if not found.
+ client.get("/withdrawals/${opId.withdrawal_id}") {
+ basicAuth("merchant", "merchant-password")
+ }.assertOk()
+ }
+
+ // Testing withdrawal confirmation
+ @Test
+ fun withdrawalConfirmation() = bankSetup { db ->
+ // Artificially making a withdrawal operation for merchant.
+ val uuid = UUID.randomUUID()
+ assert(db.talerWithdrawalCreate(
+ opUUID = uuid,
+ walletBankAccount = 1L,
+ amount = TalerAmount(1, 0, "KUDOS")
+ ))
+ // Specifying the exchange via its Payto URI.
+ assert(db.talerWithdrawalSetDetails(
+ opUuid = uuid,
+ exchangePayto = IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ"),
+ reservePub = "UNCHECKED-RESERVE-PUB"
+ ))
+
+ // Starting the bank and POSTing as Foo to /confirm the operation.
+ client.post("/withdrawals/${uuid}/confirm") {
+ basicAuth("merchant", "merchant-password")
+ }.assertOk()
+ }
+
+ // Testing the generation of taler://withdraw-URIs.
+ @Test
+ fun testWithdrawUri() {
+ // Checking the taler+http://-style.
+ val withHttp = getTalerWithdrawUri(
+ "http://example.com",
+ "my-id"
+ )
+ assertEquals(withHttp, "taler+http://withdraw/example.com/taler-integration/my-id")
+ // Checking the taler://-style
+ val onlyTaler = getTalerWithdrawUri(
+ "https://example.com/",
+ "my-id"
+ )
+ // Note: this tests as well that no double slashes belong to the result
+ assertEquals(onlyTaler, "taler://withdraw/example.com/taler-integration/my-id")
+ // Checking the removal of subsequent slashes
+ val manySlashes = getTalerWithdrawUri(
+ "https://www.example.com//////",
+ "my-id"
+ )
+ assertEquals(manySlashes, "taler://withdraw/www.example.com/taler-integration/my-id")
+ // Checking with specified port number
+ val withPort = getTalerWithdrawUri(
+ "https://www.example.com:9876",
+ "my-id"
+ )
+ assertEquals(withPort, "taler://withdraw/www.example.com:9876/taler-integration/my-id")
+ }
+} \ No newline at end of file
diff --git a/bank/src/test/kotlin/TalerApiTest.kt b/bank/src/test/kotlin/TalerApiTest.kt
deleted file mode 100644
index 59da1a94..00000000
--- a/bank/src/test/kotlin/TalerApiTest.kt
+++ /dev/null
@@ -1,751 +0,0 @@
-import io.ktor.client.plugins.*
-import io.ktor.client.request.*
-import io.ktor.client.statement.*
-import io.ktor.client.HttpClient
-import io.ktor.http.*
-import io.ktor.server.testing.*
-import kotlinx.serialization.json.*
-import kotlinx.coroutines.*
-import net.taler.wallet.crypto.Base32Crockford
-import org.junit.Test
-import tech.libeufin.bank.*
-import tech.libeufin.util.CryptoUtil
-import tech.libeufin.util.stripIbanPayto
-import java.util.*
-import java.time.Instant
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
-import randHashCode
-
-class TalerApiTest {
- private val customerFoo = Customer(
- login = "foo",
- passwordHash = CryptoUtil.hashpw("pw"),
- name = "Foo",
- phone = "+00",
- email = "foo@b.ar",
- cashoutPayto = "payto://external-IBAN",
- cashoutCurrency = "KUDOS"
- )
- private val bankAccountFoo = BankAccount(
- internalPaytoUri = IbanPayTo("payto://iban/FOO-IBAN-XYZ"),
- lastNexusFetchRowId = 1L,
- owningCustomerId = 1L,
- hasDebt = false,
- maxDebt = TalerAmount(10, 1, "KUDOS"),
- isTalerExchange = false
- )
- val customerBar = Customer(
- login = "bar",
- passwordHash = CryptoUtil.hashpw("secret"),
- name = "Bar",
- phone = "+00",
- email = "foo@b.ar",
- cashoutPayto = "payto://external-IBAN",
- cashoutCurrency = "KUDOS"
- )
- val bankAccountBar = BankAccount(
- internalPaytoUri = IbanPayTo("payto://iban/BAR-IBAN-ABC"),
- lastNexusFetchRowId = 1L,
- owningCustomerId = 2L,
- hasDebt = false,
- maxDebt = TalerAmount(10, 1, "KUDOS"),
- isTalerExchange = true
- )
-
-
- suspend fun Database.genTransfer(from: String, to: BankAccount, amount: String = "KUDOS:10") {
- talerTransferCreate(
- req = TransferRequest(
- request_uid = randHashCode(),
- amount = TalerAmount(amount),
- exchange_base_url = ExchangeUrl("http://exchange.example.com/"),
- wtid = randShortHashCode(),
- credit_account = to.internalPaytoUri
- ),
- username = from,
- timestamp = Instant.now()
- ).run {
- assertEquals(TalerTransferResult.SUCCESS, txResult)
- }
- }
-
- suspend fun Database.genIncoming(to: String, from: BankAccount) {
- talerAddIncomingCreate(
- req = AddIncomingRequest(
- reserve_pub = randShortHashCode(),
- amount = TalerAmount( 10, 0, "KUDOS"),
- debit_account = from.internalPaytoUri,
- ),
- username = to,
- timestamp = Instant.now()
- ).run {
- assertEquals(TalerAddIncomingResult.SUCCESS, txResult)
- }
- }
-
- fun commonSetup(lambda: suspend (Database, BankApplicationContext) -> Unit) {
- setup { db, ctx ->
- // Creating the exchange and merchant accounts first.
- assertNotNull(db.customerCreate(customerFoo))
- assertNotNull(db.bankAccountCreate(bankAccountFoo))
- assertNotNull(db.customerCreate(customerBar))
- assertNotNull(db.bankAccountCreate(bankAccountBar))
- lambda(db, ctx)
- }
- }
-
- // Test endpoint is correctly authenticated
- suspend fun authRoutine(client: HttpClient, path: String, body: JsonObject? = null, method: HttpMethod = HttpMethod.Post) {
- // No body when authentication must happen before parsing the body
-
- // Unknown account
- client.request(path) {
- this.method = method
- basicAuth("unknown", "password")
- }.assertStatus(HttpStatusCode.Unauthorized)
-
- // Wrong password
- client.request(path) {
- this.method = method
- basicAuth("foo", "wrong_password")
- }.assertStatus(HttpStatusCode.Unauthorized)
-
- // Wrong account
- client.request(path) {
- this.method = method
- basicAuth("bar", "secret")
- }.assertStatus(HttpStatusCode.Unauthorized)
-
- // Not exchange account
- client.request(path) {
- this.method = method
- if (body != null) jsonBody(body)
- basicAuth("foo", "pw")
- }.assertStatus(HttpStatusCode.Conflict)
- }
-
- // Testing the POST /transfer call from the TWG API.
- @Test
- fun transfer() = commonSetup { db, ctx ->
- // Do POST /transfer.
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
-
- val valid_req = json {
- "request_uid" to randHashCode()
- "amount" to "KUDOS:55"
- "exchange_base_url" to "http://exchange.example.com/"
- "wtid" to randShortHashCode()
- "credit_account" to bankAccountFoo.internalPaytoUri
- };
-
- authRoutine(client, "/accounts/foo/taler-wire-gateway/transfer", valid_req)
-
- // Checking exchange debt constraint.
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(valid_req)
- }.assertStatus(HttpStatusCode.Conflict)
-
- // Giving debt allowance and checking the OK case.
- assert(db.bankAccountSetMaxDebt(
- 2L,
- TalerAmount(1000, 0, "KUDOS")
- ))
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(valid_req)
- }.assertOk()
-
- // check idempotency
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(valid_req)
- }.assertOk()
-
- // Trigger conflict due to reused request_uid
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "wtid" to randShortHashCode()
- "exchange_base_url" to "http://different-exchange.example.com/"
- }
- )
- }.assertStatus(HttpStatusCode.Conflict)
-
- // Currency mismatch
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "amount" to "EUR:33"
- }
- )
- }.assertBadRequest()
-
- // Unknown account
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "request_uid" to randHashCode()
- "wtid" to randShortHashCode()
- "credit_account" to "payto://iban/UNKNOWN-IBAN-XYZ"
- }
- )
- }.assertStatus(HttpStatusCode.NotFound)
-
- // Bad BASE32 wtid
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "wtid" to "I love chocolate"
- }
- )
- }.assertBadRequest()
-
- // Bad BASE32 len wtid
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "wtid" to randBase32Crockford(31)
- }
- )
- }.assertBadRequest()
-
- // Bad BASE32 request_uid
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "request_uid" to "I love chocolate"
- }
- )
- }.assertBadRequest()
-
- // Bad BASE32 len wtid
- client.post("/accounts/bar/taler-wire-gateway/transfer") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "request_uid" to randBase32Crockford(65)
- }
- )
- }.assertBadRequest()
- }
- }
-
- /**
- * Testing the /history/incoming call from the TWG API.
- */
- @Test
- fun historyIncoming() = commonSetup { db, ctx ->
- // Give Foo reasonable debt allowance:
- assert(
- db.bankAccountSetMaxDebt(
- 1L,
- TalerAmount(1000000, 0, "KUDOS")
- )
- )
-
- suspend fun HttpResponse.assertHistory(size: Int) {
- assertOk()
- val txt = this.bodyAsText()
- val history = Json.decodeFromString<IncomingHistory>(txt)
- val params = getHistoryParams(this.call.request.url.parameters)
-
- // testing the size is like expected.
- assert(history.incoming_transactions.size == size) {
- println("incoming_transactions has wrong size: ${history.incoming_transactions.size}")
- println("Response was: ${txt}")
- }
- if (params.delta < 0) {
- // testing that the first row_id is at most the 'start' query param.
- assert(history.incoming_transactions[0].row_id <= params.start)
- // testing that the row_id decreases.
- assert(history.incoming_transactions.windowed(2).all { (a, b) -> a.row_id > b.row_id })
- } else {
- // testing that the first row_id is at least the 'start' query param.
- assert(history.incoming_transactions[0].row_id >= params.start)
- // testing that the row_id increases.
- assert(history.incoming_transactions.windowed(2).all { (a, b) -> a.row_id < b.row_id })
- }
- }
-
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
-
- authRoutine(client, "/accounts/foo/taler-wire-gateway/history/incoming?delta=7", method = HttpMethod.Get)
-
- // Check error when no transactions
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=7") {
- basicAuth("bar", "secret")
- }.assertStatus(HttpStatusCode.NoContent)
-
- // Gen three transactions using clean add incoming logic
- repeat(3) {
- db.genIncoming("bar", bankAccountFoo)
- }
- // Should not show up in the taler wire gateway API history
- db.bankTransactionCreate(genTx("bogus foobar")).assertSuccess()
- // Bar pays Foo once, but that should not appear in the result.
- db.bankTransactionCreate(genTx("payout", creditorId = 1, debtorId = 2)).assertSuccess()
- // Gen two transactions using row bank transaction logic
- repeat(2) {
- db.bankTransactionCreate(
- genTx(IncomingTxMetadata(randShortHashCode()).encode(), 2, 1)
- ).assertSuccess()
- }
-
- // Check ignore bogus subject
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=7") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
-
- // Check skip bogus subject
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=5") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
-
- // Check no useless polling
- assertTime(0, 300) {
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=-6&start=15&long_poll_ms=1000") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
- }
-
- // Check polling end
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=6&long_poll_ms=60") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
-
- runBlocking {
- joinAll(
- launch { // Check polling succeed forward
- assertTime(200, 1000) {
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=6&long_poll_ms=1000") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch { // Check polling succeed backward
- assertTime(200, 1000) {
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=-6&long_poll_ms=1000") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch { // Check polling timeout forward
- assertTime(200, 400) {
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=8&long_poll_ms=300") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch { // Check polling timeout backward
- assertTime(200, 400) {
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=-8&long_poll_ms=300") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch {
- delay(200)
- db.genIncoming("bar", bankAccountFoo)
- }
- )
- }
-
- // Testing ranges.
- repeat(300) {
- db.genIncoming("bar", bankAccountFoo)
- }
-
- // forward range:
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=10&start=30") {
- basicAuth("bar", "secret")
- }.assertHistory(10)
-
- // backward range:
- client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=-10&start=300") {
- basicAuth("bar", "secret")
- }.assertHistory(10)
- }
- }
-
-
- /**
- * Testing the /history/outgoing call from the TWG API.
- */
- @Test
- fun historyOutgoing() = commonSetup { db, ctx ->
- // Give Bar reasonable debt allowance:
- assert(
- db.bankAccountSetMaxDebt(
- 2L,
- TalerAmount(1000000, 0, "KUDOS")
- )
- )
-
- suspend fun HttpResponse.assertHistory(size: Int) {
- assertOk()
- val txt = this.bodyAsText()
- val history = Json.decodeFromString<OutgoingHistory>(txt)
- val params = getHistoryParams(this.call.request.url.parameters)
-
- // testing the size is like expected.
- assert(history.outgoing_transactions.size == size) {
- println("outgoing_transactions has wrong size: ${history.outgoing_transactions.size}")
- println("Response was: ${txt}")
- }
- if (params.delta < 0) {
- // testing that the first row_id is at most the 'start' query param.
- assert(history.outgoing_transactions[0].row_id <= params.start)
- // testing that the row_id decreases.
- assert(history.outgoing_transactions.windowed(2).all { (a, b) -> a.row_id > b.row_id })
- } else {
- // testing that the first row_id is at least the 'start' query param.
- assert(history.outgoing_transactions[0].row_id >= params.start)
- // testing that the row_id increases.
- assert(history.outgoing_transactions.windowed(2).all { (a, b) -> a.row_id < b.row_id })
- }
- }
-
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
-
- authRoutine(client, "/accounts/foo/taler-wire-gateway/history/outgoing?delta=7", method = HttpMethod.Get)
-
- // Check error when no transactions
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=7") {
- basicAuth("bar", "secret")
- }.assertStatus(HttpStatusCode.NoContent)
-
- // Gen three transactions using clean transfer logic
- repeat(3) {
- db.genTransfer("bar", bankAccountFoo)
- }
- // Should not show up in the taler wire gateway API history
- db.bankTransactionCreate(genTx("bogus foobar", 1, 2)).assertSuccess()
- // Foo pays Bar once, but that should not appear in the result.
- db.bankTransactionCreate(genTx("payout")).assertSuccess()
- // Gen two transactions using row bank transaction logic
- repeat(2) {
- db.bankTransactionCreate(
- genTx(OutgoingTxMetadata(randShortHashCode(), ExchangeUrl("http://exchange.example.com/")).encode(), 1, 2)
- ).assertSuccess()
- }
-
- // Check ignore bogus subject
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=7") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
-
- // Check skip bogus subject
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=5") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
-
- // Check no useless polling
- assertTime(0, 300) {
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=-6&start=15&long_poll_ms=1000") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
- }
-
- // Check polling end
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=6&long_poll_ms=60") {
- basicAuth("bar", "secret")
- }.assertHistory(5)
-
- runBlocking {
- joinAll(
- launch { // Check polling succeed forward
- assertTime(200, 1000) {
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=6&long_poll_ms=1000") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch { // Check polling succeed backward
- assertTime(200, 1000) {
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=-6&long_poll_ms=1000") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch { // Check polling timeout forward
- assertTime(200, 400) {
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=8&long_poll_ms=300") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch { // Check polling timeout backward
- assertTime(200, 400) {
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=-8&long_poll_ms=300") {
- basicAuth("bar", "secret")
- }.assertHistory(6)
- }
- },
- launch {
- delay(200)
- db.genTransfer("bar", bankAccountFoo)
- }
- )
- }
-
- // Testing ranges.
- repeat(300) {
- db.genTransfer("bar", bankAccountFoo)
- }
-
- // forward range:
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=10&start=30") {
- basicAuth("bar", "secret")
- }.assertHistory(10)
-
- // backward range:
- client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=-10&start=300") {
- basicAuth("bar", "secret")
- }.assertHistory(10)
- }
- }
-
- // Testing the /admin/add-incoming call from the TWG API.
- @Test
- fun addIncoming() = commonSetup { db, ctx ->
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
-
- val valid_req = json {
- "amount" to "KUDOS:44"
- "reserve_pub" to randEddsaPublicKey()
- "debit_account" to bankAccountFoo.internalPaytoUri
- };
-
- authRoutine(client, "/accounts/foo/taler-wire-gateway/admin/add-incoming", valid_req)
-
- // Checking exchange debt constraint.
- client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") {
- basicAuth("bar", "secret")
- jsonBody(valid_req)
- }.assertStatus(HttpStatusCode.Conflict)
-
- // Giving debt allowance and checking the OK case.
- assert(db.bankAccountSetMaxDebt(
- 1L,
- TalerAmount(1000, 0, "KUDOS")
- ))
-
- client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") {
- basicAuth("bar", "secret")
- jsonBody(valid_req, deflate = true)
- }.assertOk()
-
- // Trigger conflict due to reused reserve_pub
- client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") {
- basicAuth("bar", "secret")
- jsonBody(valid_req)
- }.assertStatus(HttpStatusCode.Conflict)
-
- // Currency mismatch
- client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "amount" to "EUR:33"
- }
- )
- }.assertBadRequest()
-
- // Unknown account
- client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") {
- basicAuth("bar", "secret")
- jsonBody(
- json(valid_req) {
- "reserve_pub" to randEddsaPublicKey()
- "debit_account" to "payto://iban/UNKNOWN-IBAN-XYZ"
- }
- )
- }.assertStatus(HttpStatusCode.NotFound)
-
- // Bad BASE32 reserve_pub
- client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") {
- basicAuth("bar", "secret")
- jsonBody(json(valid_req) {
- "reserve_pub" to "I love chocolate"
- })
- }.assertBadRequest()
-
- // Bad BASE32 len reserve_pub
- client.post("/accounts/bar/taler-wire-gateway/admin/add-incoming") {
- basicAuth("bar", "secret")
- jsonBody(json(valid_req) {
- "reserve_pub" to randBase32Crockford(31)
- })
- }.assertBadRequest()
- }
- }
- // Selecting withdrawal details from the Integration API endpoint.
- @Test
- fun intSelect() = setup { db, ctx ->
- val uuid = UUID.randomUUID()
- assertNotNull(db.customerCreate(customerFoo))
- assertNotNull(db.bankAccountCreate(bankAccountFoo))
- // insert new.
- assert(db.talerWithdrawalCreate(
- opUUID = uuid,
- walletBankAccount = 1L,
- amount = TalerAmount(1, 0, "KUDOS")
- ))
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
- val r = client.post("/taler-integration/withdrawal-operation/${uuid}") {
- jsonBody(BankWithdrawalOperationPostRequest(
- reserve_pub = "RESERVE-FOO",
- selected_exchange = IbanPayTo("payto://iban/ABC123")
- ))
- }.assertOk()
- println(r.bodyAsText())
- }
- }
- // Showing withdrawal details from the Integrtion API endpoint.
- @Test
- fun intGet() = setup { db, ctx ->
- val uuid = UUID.randomUUID()
- assert(db.customerCreate(customerFoo) != null)
- assert(db.bankAccountCreate(bankAccountFoo) != null)
- // insert new.
- assert(db.talerWithdrawalCreate(
- opUUID = uuid,
- walletBankAccount = 1L,
- amount = TalerAmount(1, 0, "KUDOS")
- ))
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
- val r = client.get("/taler-integration/withdrawal-operation/${uuid}").assertOk()
- println(r.bodyAsText())
- }
- }
- // Testing withdrawal abort
- @Test
- fun withdrawalAbort() = setup { db, ctx ->
- val uuid = UUID.randomUUID()
- assert(db.customerCreate(customerFoo) != null)
- assert(db.bankAccountCreate(bankAccountFoo) != null)
- // insert new.
- assert(db.talerWithdrawalCreate(
- opUUID = uuid,
- walletBankAccount = 1L,
- amount = TalerAmount(1, 0, "KUDOS")
- ))
- val op = db.talerWithdrawalGet(uuid)
- assert(op?.aborted == false)
- assert(db.talerWithdrawalSetDetails(uuid, IbanPayTo("payto://iban/exchange-payto"), "reserve_pub"))
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
- client.post("/withdrawals/${uuid}/abort") {
- basicAuth("foo", "pw")
- }.assertOk()
- }
- val opAbo = db.talerWithdrawalGet(uuid)
- assert(opAbo?.aborted == true && opAbo.selectionDone == true)
- }
- // Testing withdrawal creation
- @Test
- fun withdrawalCreation() = setup { db, ctx ->
- assertNotNull(db.customerCreate(customerFoo))
- assertNotNull(db.bankAccountCreate(bankAccountFoo))
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
- // Creating the withdrawal as if the SPA did it.
- val r = client.post("/accounts/foo/withdrawals") {
- basicAuth("foo", "pw")
- jsonBody(BankAccountCreateWithdrawalRequest(TalerAmount(value = 9, frac = 0, currency = "KUDOS")))
- }.assertOk()
- val opId = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(r.bodyAsText())
- // Getting the withdrawal from the bank. Throws (failing the test) if not found.
- client.get("/withdrawals/${opId.withdrawal_id}") {
- basicAuth("foo", "pw")
- }.assertOk()
- }
- }
- // Testing withdrawal confirmation
- @Test
- fun withdrawalConfirmation() = commonSetup { db, ctx ->
-
- // Artificially making a withdrawal operation for Foo.
- val uuid = UUID.randomUUID()
- assert(db.talerWithdrawalCreate(
- opUUID = uuid,
- walletBankAccount = 1L,
- amount = TalerAmount(1, 0, "KUDOS")
- ))
- // Specifying Bar as the exchange, via its Payto URI.
- assert(db.talerWithdrawalSetDetails(
- opUuid = uuid,
- exchangePayto = IbanPayTo("payto://iban/BAR-IBAN-ABC"),
- reservePub = "UNCHECKED-RESERVE-PUB"
- ))
-
- // Starting the bank and POSTing as Foo to /confirm the operation.
- testApplication {
- application {
- corebankWebApp(db, ctx)
- }
- client.post("/withdrawals/${uuid}/confirm") {
- basicAuth("foo", "pw")
- }.assertOk()
- }
- }
- // Testing the generation of taler://withdraw-URIs.
- @Test
- fun testWithdrawUri() {
- // Checking the taler+http://-style.
- val withHttp = getTalerWithdrawUri(
- "http://example.com",
- "my-id"
- )
- assertEquals(withHttp, "taler+http://withdraw/example.com/taler-integration/my-id")
- // Checking the taler://-style
- val onlyTaler = getTalerWithdrawUri(
- "https://example.com/",
- "my-id"
- )
- // Note: this tests as well that no double slashes belong to the result
- assertEquals(onlyTaler, "taler://withdraw/example.com/taler-integration/my-id")
- // Checking the removal of subsequent slashes
- val manySlashes = getTalerWithdrawUri(
- "https://www.example.com//////",
- "my-id"
- )
- assertEquals(manySlashes, "taler://withdraw/www.example.com/taler-integration/my-id")
- // Checking with specified port number
- val withPort = getTalerWithdrawUri(
- "https://www.example.com:9876",
- "my-id"
- )
- assertEquals(withPort, "taler://withdraw/www.example.com:9876/taler-integration/my-id")
- }
-} \ No newline at end of file
diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt
new file mode 100644
index 00000000..34b32296
--- /dev/null
+++ b/bank/src/test/kotlin/WireGatewayApiTest.kt
@@ -0,0 +1,529 @@
+import io.ktor.client.plugins.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.client.HttpClient
+import io.ktor.http.*
+import io.ktor.server.testing.*
+import kotlinx.serialization.json.*
+import kotlinx.coroutines.*
+import org.junit.Test
+import tech.libeufin.bank.*
+import tech.libeufin.util.CryptoUtil
+import java.util.*
+import java.time.Instant
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import randHashCode
+
+class WireGatewayApiTest {
+ suspend fun Database.genTransfer(from: String, to: BankAccount, amount: String = "KUDOS:10") {
+ talerTransferCreate(
+ req = TransferRequest(
+ request_uid = randHashCode(),
+ amount = TalerAmount(amount),
+ exchange_base_url = ExchangeUrl("http://exchange.example.com/"),
+ wtid = randShortHashCode(),
+ credit_account = to.internalPaytoUri
+ ),
+ username = from,
+ timestamp = Instant.now()
+ ).run {
+ assertEquals(TalerTransferResult.SUCCESS, txResult)
+ }
+ }
+
+ suspend fun Database.genIncoming(to: String, from: BankAccount) {
+ talerAddIncomingCreate(
+ req = AddIncomingRequest(
+ reserve_pub = randShortHashCode(),
+ amount = TalerAmount(10, 0, "KUDOS"),
+ debit_account = from.internalPaytoUri,
+ ),
+ username = to,
+ timestamp = Instant.now()
+ ).run {
+ assertEquals(TalerAddIncomingResult.SUCCESS, txResult)
+ }
+ }
+
+ // Test endpoint is correctly authenticated
+ suspend fun authRoutine(client: HttpClient, path: String, body: JsonObject? = null, method: HttpMethod = HttpMethod.Post) {
+ // No body when authentication must happen before parsing the body
+
+ // Unknown account
+ client.request(path) {
+ this.method = method
+ basicAuth("unknown", "password")
+ }.assertStatus(HttpStatusCode.Unauthorized)
+
+ // Wrong password
+ client.request(path) {
+ this.method = method
+ basicAuth("merchant", "wrong-password")
+ }.assertStatus(HttpStatusCode.Unauthorized)
+
+ // Wrong account
+ client.request(path) {
+ this.method = method
+ basicAuth("exchange", "merchant-password")
+ }.assertStatus(HttpStatusCode.Unauthorized)
+
+ // Not exchange account
+ client.request(path) {
+ this.method = method
+ if (body != null) jsonBody(body)
+ basicAuth("merchant", "merchant-password")
+ }.assertStatus(HttpStatusCode.Conflict)
+ }
+
+ // Testing the POST /transfer call from the TWG API.
+ @Test
+ fun transfer() = bankSetup { db ->
+ val valid_req = json {
+ "request_uid" to randHashCode()
+ "amount" to "KUDOS:55"
+ "exchange_base_url" to "http://exchange.example.com/"
+ "wtid" to randShortHashCode()
+ "credit_account" to "payto://iban/MERCHANT-IBAN-XYZ"
+ };
+
+ authRoutine(client, "/accounts/merchant/taler-wire-gateway/transfer", valid_req)
+
+ // Checking exchange debt constraint.
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(valid_req)
+ }.assertStatus(HttpStatusCode.Conflict)
+
+ // Giving debt allowance and checking the OK case.
+ assert(db.bankAccountSetMaxDebt(
+ 2L,
+ TalerAmount(1000, 0, "KUDOS")
+ ))
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(valid_req)
+ }.assertOk()
+
+ // check idempotency
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(valid_req)
+ }.assertOk()
+
+ // Trigger conflict due to reused request_uid
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "wtid" to randShortHashCode()
+ "exchange_base_url" to "http://different-exchange.example.com/"
+ }
+ )
+ }.assertStatus(HttpStatusCode.Conflict)
+
+ // Currency mismatch
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "amount" to "EUR:33"
+ }
+ )
+ }.assertBadRequest()
+
+ // Unknown account
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "request_uid" to randHashCode()
+ "wtid" to randShortHashCode()
+ "credit_account" to "payto://iban/UNKNOWN-IBAN-XYZ"
+ }
+ )
+ }.assertStatus(HttpStatusCode.NotFound)
+
+ // Bad BASE32 wtid
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "wtid" to "I love chocolate"
+ }
+ )
+ }.assertBadRequest()
+
+ // Bad BASE32 len wtid
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "wtid" to randBase32Crockford(31)
+ }
+ )
+ }.assertBadRequest()
+
+ // Bad BASE32 request_uid
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "request_uid" to "I love chocolate"
+ }
+ )
+ }.assertBadRequest()
+
+ // Bad BASE32 len wtid
+ client.post("/accounts/exchange/taler-wire-gateway/transfer") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "request_uid" to randBase32Crockford(65)
+ }
+ )
+ }.assertBadRequest()
+ }
+
+ /**
+ * Testing the /history/incoming call from the TWG API.
+ */
+ @Test
+ fun historyIncoming() = bankSetup { db ->
+ // Give Foo reasonable debt allowance:
+ assert(
+ db.bankAccountSetMaxDebt(
+ 1L,
+ TalerAmount(1000000, 0, "KUDOS")
+ )
+ )
+
+ suspend fun HttpResponse.assertHistory(size: Int) {
+ assertOk()
+ val txt = this.bodyAsText()
+ val history = Json.decodeFromString<IncomingHistory>(txt)
+ val params = getHistoryParams(this.call.request.url.parameters)
+
+ // testing the size is like expected.
+ assert(history.incoming_transactions.size == size) {
+ println("incoming_transactions has wrong size: ${history.incoming_transactions.size}")
+ println("Response was: ${txt}")
+ }
+ if (params.delta < 0) {
+ // testing that the first row_id is at most the 'start' query param.
+ assert(history.incoming_transactions[0].row_id <= params.start)
+ // testing that the row_id decreases.
+ assert(history.incoming_transactions.windowed(2).all { (a, b) -> a.row_id > b.row_id })
+ } else {
+ // testing that the first row_id is at least the 'start' query param.
+ assert(history.incoming_transactions[0].row_id >= params.start)
+ // testing that the row_id increases.
+ assert(history.incoming_transactions.windowed(2).all { (a, b) -> a.row_id < b.row_id })
+ }
+ }
+
+
+ authRoutine(client, "/accounts/merchant/taler-wire-gateway/history/incoming?delta=7", method = HttpMethod.Get)
+
+ // Check error when no transactions
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=7") {
+ basicAuth("exchange", "exchange-password")
+ }.assertStatus(HttpStatusCode.NoContent)
+
+ // Gen three transactions using clean add incoming logic
+ repeat(3) {
+ db.genIncoming("exchange", bankAccountMerchant)
+ }
+ // Should not show up in the taler wire gateway API history
+ db.bankTransactionCreate(genTx("bogus foobar")).assertSuccess()
+ // Bar pays Foo once, but that should not appear in the result.
+ db.bankTransactionCreate(genTx("payout", creditorId = 1, debtorId = 2)).assertSuccess()
+ // Gen two transactions using row bank transaction logic
+ repeat(2) {
+ db.bankTransactionCreate(
+ genTx(IncomingTxMetadata(randShortHashCode()).encode(), 2, 1)
+ ).assertSuccess()
+ }
+
+ // Check ignore bogus subject
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=7") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+
+ // Check skip bogus subject
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=5") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+
+ // Check no useless polling
+ assertTime(0, 300) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-6&start=15&long_poll_ms=1000") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+ }
+
+ // Check polling end
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=6&long_poll_ms=60") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+
+ runBlocking {
+ joinAll(
+ launch { // Check polling succeed forward
+ assertTime(200, 1000) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=6&long_poll_ms=1000") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch { // Check polling succeed backward
+ assertTime(200, 1000) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-6&long_poll_ms=1000") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch { // Check polling timeout forward
+ assertTime(200, 400) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=8&long_poll_ms=300") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch { // Check polling timeout backward
+ assertTime(200, 400) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-8&long_poll_ms=300") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch {
+ delay(200)
+ db.genIncoming("exchange", bankAccountMerchant)
+ }
+ )
+ }
+
+ // Testing ranges.
+ repeat(300) {
+ db.genIncoming("exchange", bankAccountMerchant)
+ }
+
+ // forward range:
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=10&start=30") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(10)
+
+ // backward range:
+ client.get("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-10&start=300") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(10)
+ }
+
+
+ /**
+ * Testing the /history/outgoing call from the TWG API.
+ */
+ @Test
+ fun historyOutgoing() = bankSetup { db ->
+ // Give Bar reasonable debt allowance:
+ assert(
+ db.bankAccountSetMaxDebt(
+ 2L,
+ TalerAmount(1000000, 0, "KUDOS")
+ )
+ )
+
+ suspend fun HttpResponse.assertHistory(size: Int) {
+ assertOk()
+ val txt = this.bodyAsText()
+ val history = Json.decodeFromString<OutgoingHistory>(txt)
+ val params = getHistoryParams(this.call.request.url.parameters)
+
+ // testing the size is like expected.
+ assert(history.outgoing_transactions.size == size) {
+ println("outgoing_transactions has wrong size: ${history.outgoing_transactions.size}")
+ println("Response was: ${txt}")
+ }
+ if (params.delta < 0) {
+ // testing that the first row_id is at most the 'start' query param.
+ assert(history.outgoing_transactions[0].row_id <= params.start)
+ // testing that the row_id decreases.
+ assert(history.outgoing_transactions.windowed(2).all { (a, b) -> a.row_id > b.row_id })
+ } else {
+ // testing that the first row_id is at least the 'start' query param.
+ assert(history.outgoing_transactions[0].row_id >= params.start)
+ // testing that the row_id increases.
+ assert(history.outgoing_transactions.windowed(2).all { (a, b) -> a.row_id < b.row_id })
+ }
+ }
+
+ authRoutine(client, "/accounts/merchant/taler-wire-gateway/history/outgoing?delta=7", method = HttpMethod.Get)
+
+ // Check error when no transactions
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=7") {
+ basicAuth("exchange", "exchange-password")
+ }.assertStatus(HttpStatusCode.NoContent)
+
+ // Gen three transactions using clean transfer logic
+ repeat(3) {
+ db.genTransfer("exchange", bankAccountMerchant)
+ }
+ // Should not show up in the taler wire gateway API history
+ db.bankTransactionCreate(genTx("bogus foobar", 1, 2)).assertSuccess()
+ // Foo pays Bar once, but that should not appear in the result.
+ db.bankTransactionCreate(genTx("payout")).assertSuccess()
+ // Gen two transactions using row bank transaction logic
+ repeat(2) {
+ db.bankTransactionCreate(
+ genTx(OutgoingTxMetadata(randShortHashCode(), ExchangeUrl("http://exchange.example.com/")).encode(), 1, 2)
+ ).assertSuccess()
+ }
+
+ // Check ignore bogus subject
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=7") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+
+ // Check skip bogus subject
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=5") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+
+ // Check no useless polling
+ assertTime(0, 300) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-6&start=15&long_poll_ms=1000") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+ }
+
+ // Check polling end
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=6&long_poll_ms=60") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(5)
+
+ runBlocking {
+ joinAll(
+ launch { // Check polling succeed forward
+ assertTime(200, 1000) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=6&long_poll_ms=1000") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch { // Check polling succeed backward
+ assertTime(200, 1000) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-6&long_poll_ms=1000") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch { // Check polling timeout forward
+ assertTime(200, 400) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=8&long_poll_ms=300") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch { // Check polling timeout backward
+ assertTime(200, 400) {
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-8&long_poll_ms=300") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(6)
+ }
+ },
+ launch {
+ delay(200)
+ db.genTransfer("exchange", bankAccountMerchant)
+ }
+ )
+ }
+
+ // Testing ranges.
+ repeat(300) {
+ db.genTransfer("exchange", bankAccountMerchant)
+ }
+
+ // forward range:
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=10&start=30") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(10)
+
+ // backward range:
+ client.get("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-10&start=300") {
+ basicAuth("exchange", "exchange-password")
+ }.assertHistory(10)
+ }
+
+ // Testing the /admin/add-incoming call from the TWG API.
+ @Test
+ fun addIncoming() = bankSetup { db ->
+ val valid_req = json {
+ "amount" to "KUDOS:44"
+ "reserve_pub" to randEddsaPublicKey()
+ "debit_account" to "payto://iban/MERCHANT-IBAN-XYZ"
+ };
+
+ authRoutine(client, "/accounts/merchant/taler-wire-gateway/admin/add-incoming", valid_req)
+
+ // Checking exchange debt constraint.
+ client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(valid_req)
+ }.assertStatus(HttpStatusCode.Conflict)
+
+ // Giving debt allowance and checking the OK case.
+ assert(db.bankAccountSetMaxDebt(
+ 1L,
+ TalerAmount(1000, 0, "KUDOS")
+ ))
+
+ client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(valid_req, deflate = true)
+ }.assertOk()
+
+ // Trigger conflict due to reused reserve_pub
+ client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(valid_req)
+ }.assertStatus(HttpStatusCode.Conflict)
+
+ // Currency mismatch
+ client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "amount" to "EUR:33"
+ }
+ )
+ }.assertBadRequest()
+
+ // Unknown account
+ client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(
+ json(valid_req) {
+ "reserve_pub" to randEddsaPublicKey()
+ "debit_account" to "payto://iban/UNKNOWN-IBAN-XYZ"
+ }
+ )
+ }.assertStatus(HttpStatusCode.NotFound)
+
+ // Bad BASE32 reserve_pub
+ client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(json(valid_req) {
+ "reserve_pub" to "I love chocolate"
+ })
+ }.assertBadRequest()
+
+ // Bad BASE32 len reserve_pub
+ client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
+ basicAuth("exchange", "exchange-password")
+ jsonBody(json(valid_req) {
+ "reserve_pub" to randBase32Crockford(31)
+ })
+ }.assertBadRequest()
+ }
+} \ No newline at end of file
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
index d878ad2c..12dc7434 100644
--- a/bank/src/test/kotlin/helpers.kt
+++ b/bank/src/test/kotlin/helpers.kt
@@ -1,16 +1,68 @@
import io.ktor.http.*
import io.ktor.client.statement.*
import io.ktor.client.request.*
+import io.ktor.server.testing.*
import kotlinx.coroutines.*
import kotlinx.serialization.json.*
import net.taler.wallet.crypto.Base32Crockford
-import kotlin.test.assertEquals
+import kotlin.test.*
import tech.libeufin.bank.*
import java.io.ByteArrayOutputStream
import java.util.zip.DeflaterOutputStream
+import tech.libeufin.util.CryptoUtil
/* ----- Setup ----- */
+val customerMerchant = Customer(
+ login = "merchant",
+ passwordHash = CryptoUtil.hashpw("merchant-password"),
+ name = "Merchant",
+ phone = "+00",
+ email = "merchant@libeufin-bank.com",
+ cashoutPayto = "payto://external-IBAN",
+ cashoutCurrency = "KUDOS"
+)
+val bankAccountMerchant = BankAccount(
+ internalPaytoUri = IbanPayTo("payto://iban/MERCHANT-IBAN-XYZ"),
+ lastNexusFetchRowId = 1L,
+ owningCustomerId = 1L,
+ hasDebt = false,
+ maxDebt = TalerAmount(10, 1, "KUDOS"),
+)
+val customerExchange = Customer(
+ login = "exchange",
+ passwordHash = CryptoUtil.hashpw("exchange-password"),
+ name = "Exchange",
+ phone = "+00",
+ email = "exchange@libeufin-bank.com",
+ cashoutPayto = "payto://external-IBAN",
+ cashoutCurrency = "KUDOS"
+)
+val bankAccountExchange = BankAccount(
+ internalPaytoUri = IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ"),
+ lastNexusFetchRowId = 1L,
+ owningCustomerId = 2L,
+ hasDebt = false,
+ maxDebt = TalerAmount(10, 1, "KUDOS"),
+ isTalerExchange = true
+)
+
+fun bankSetup(lambda: suspend ApplicationTestBuilder.(Database) -> Unit) {
+ setup { db, ctx ->
+ // Creating the exchange and merchant accounts first.
+ assertNotNull(db.customerCreate(customerMerchant))
+ assertNotNull(db.bankAccountCreate(bankAccountMerchant))
+ assertNotNull(db.customerCreate(customerExchange))
+ assertNotNull(db.bankAccountCreate(bankAccountExchange))
+ testApplication {
+ application {
+ corebankWebApp(db, ctx)
+ }
+ lambda(db)
+ }
+ }
+}
+
fun setup(
conf: String = "test.conf",
lambda: suspend (Database, BankApplicationContext) -> Unit
diff --git a/database-versioning/procedures.sql b/database-versioning/procedures.sql
index 63ce8fa1..5a619e40 100644
--- a/database-versioning/procedures.sql
+++ b/database-versioning/procedures.sql
@@ -11,7 +11,7 @@ BEGIN
normalized.val = amount.val + amount.frac / 100000000;
normalized.frac = amount.frac % 100000000;
END $$;
-COMMENT ON PROCEDURE amount_normalize
+COMMENT ON FUNCTION amount_normalize
IS 'Returns the normalized amount by adding to the .val the value of (.frac / 100000000) and removing the modulus 100000000 from .frac.';
CREATE OR REPLACE FUNCTION amount_add(
@@ -29,7 +29,7 @@ BEGIN
RAISE EXCEPTION 'addition overflow';
END IF;
END $$;
-COMMENT ON PROCEDURE amount_add
+COMMENT ON FUNCTION amount_add
IS 'Returns the normalized sum of two amounts. It raises an exception when the resulting .val is larger than 2^52';
CREATE OR REPLACE FUNCTION amount_left_minus_right(