libeufin

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

commit fe22997387eefa9fb205384fe59f42d2480ea425
parent 145bfdbde140033f3761e8fc18905db2b62f7881
Author: ms <ms@taler.net>
Date:   Wed, 10 Nov 2021 16:21:04 +0100

Fixes after wallet tests harness.

- implement TWG /admin/add-incoming.
- generate checksum-valid IBANs.

Diffstat:
Aaccess-api-stash/AccessApiNexus.kt | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt | 10+++++-----
Msandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt | 50+++++++++++++++++++++++++++++++++++++++++++++++---
Mutil/src/main/kotlin/JSON.kt | 6++++++
Mutil/src/main/kotlin/Payto.kt | 4++--
Mutil/src/main/kotlin/iban.kt | 15++++++++-------
Autil/src/test/kotlin/ibanTest.kt | 11+++++++++++
7 files changed, 289 insertions(+), 17 deletions(-)

diff --git a/access-api-stash/AccessApiNexus.kt b/access-api-stash/AccessApiNexus.kt @@ -0,0 +1,210 @@ +package tech.libeufin.nexus.`access-api` + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.ktor.client.* +import io.ktor.client.features.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.util.* +import org.jetbrains.exposed.sql.not +import org.jetbrains.exposed.sql.transactions.transaction +import tech.libeufin.nexus.* +import tech.libeufin.nexus.server.AccessApiNewTransport +import tech.libeufin.nexus.server.EbicsNewTransport +import tech.libeufin.nexus.server.FetchSpecJson +import tech.libeufin.nexus.server.client +import tech.libeufin.util.* +import java.net.URL +import java.nio.charset.Charset + +private fun getAccessApiClient(connId: String): AccessApiClientEntity { + val conn = NexusBankConnectionEntity.find { + NexusBankConnectionsTable.connectionId eq connId + }.firstOrNull() ?: throw notFound("Connection '$connId' not found.") + val client = AccessApiClientEntity.find { + AccessApiClientsTable.nexusBankConnection eq conn.id.value + }.firstOrNull() ?: throw notFound("Connection '$connId' has no client data.") + return client +} + +suspend fun HttpClient.accessApiReq( + method: HttpMethod, + url: String, + body: Any? = null, + // username, password + credentials: Pair<String, String>): String? { + + val reqBuilder: HttpRequestBuilder.() -> Unit = { + contentType(ContentType.Application.Json) + if (body != null) + this.body = body + + headers.apply { + this.set( + "Authorization", + "Basic " + bytesToBase64("${credentials.first}:${credentials.second}".toByteArray(Charsets.UTF_8)) + ) + } + } + return try { + when(method) { + HttpMethod.Get -> { + this.get(url, reqBuilder) + } + HttpMethod.Post -> { + this.post(url, reqBuilder) + } + else -> throw internalServerError("Method $method not supported.") + } + } catch (e: ClientRequestException) { + logger.error(e.message) + throw NexusError( + HttpStatusCode.BadGateway, + e.message + ) + } +} + +/** + * Talk to the Sandbox via native Access API. The main reason + * for this class was to still allow x-taler-bank as a wire method + * (to accommodate wallet harness tests), and therefore skip the + * Camt+Pain schemas. + */ +class JsonBankConnectionProtocol: BankConnectionProtocol { + + override fun createConnection(connId: String, user: NexusUserEntity, data: JsonNode) { + val bankConn = NexusBankConnectionEntity.new { + this.connectionId = connId + owner = user + type = "access-api" + } + val newTransportData = jacksonObjectMapper( + ).treeToValue(data, AccessApiNewTransport::class.java) ?: throw NexusError( + HttpStatusCode.BadRequest, "Access Api details not found in request" + ) + AccessApiClientEntity.new { + username = newTransportData.username + bankURL = newTransportData.bankURL + remoteBankAccountLabel = newTransportData.remoteBankAccountLabel + nexusBankConnection = bankConn + password = newTransportData.password + } + } + + override fun getConnectionDetails(conn: NexusBankConnectionEntity): JsonNode { + val details = transaction { getAccessApiClient(conn.connectionId) } + val ret = ObjectMapper().createObjectNode() + ret.put("username", details.username) + ret.put("bankURL", details.bankURL) + ret.put("passwordHash", CryptoUtil.hashpw(details.password)) + ret.put("remoteBankAccountLabel", details.remoteBankAccountLabel) + return ret + } + + override suspend fun submitPaymentInitiation( + httpClient: HttpClient, + paymentInitiationId: Long // must refer to an x-taler-bank payto://-instruction. + ) { + val payInit = XTalerBankPaymentInitiationEntity.findById(paymentInitiationId) ?: throw notFound( + "Payment initiation '$paymentInitiationId' not found." + ) + val conn = payInit.defaultBankConnection ?: throw notFound( + "No default bank connection for payment initiation '${paymentInitiationId}' was found." + ) + val details = getAccessApiClient(conn.connectionId) + + client.accessApiReq( + method = HttpMethod.Post, + url = urlJoinNoDrop( + details.bankURL, + "accounts/${details.remoteBankAccountLabel}/transactions" + ), + body = object { + val paytoUri = payInit.paytoUri + val amount = payInit.amount + val subject = payInit.subject + }, + credentials = Pair(details.username, details.password) + ) + } + /** + * This function gets always the fresh transactions from + * the bank. Any other Wire Gateway API policies will be + * implemented by the respective facade (XTalerBank.kt) */ + override suspend fun fetchTransactions( + fetchSpec: FetchSpecJson, + client: HttpClient, + bankConnectionId: String, + /** + * Label of the local bank account that mirrors + * the remote bank account pointed to by 'bankConnectionId' */ + accountId: String + ) { + val details = getAccessApiClient(bankConnectionId) + val txsRaw = client.accessApiReq( + method = HttpMethod.Get, + url = urlJoinNoDrop( + details.bankURL, + "accounts/${details.remoteBankAccountLabel}/transactions" + ), + credentials = Pair(details.username, details.password) + ) + // What format does Access API communicates the records in? + /** + * NexusXTalerBankTransactions.new { + * + * .. details .. + * } + */ + } + + override fun exportBackup(bankConnectionId: String, passphrase: String): JsonNode { + throw NexusError( + HttpStatusCode.NotImplemented, + "Operation not needed." + ) + } + + override fun exportAnalogDetails(conn: NexusBankConnectionEntity): ByteArray { + throw NexusError( + HttpStatusCode.NotImplemented, + "Operation not needed." + ) + } + + override suspend fun fetchAccounts(client: HttpClient, connId: String) { + throw NexusError( + HttpStatusCode.NotImplemented, + "access-api connections assume that remote and local bank" + + " accounts are called the same. No need to 'fetch'" + ) + } + + override fun createConnectionFromBackup( + connId: String, + user: NexusUserEntity, + passphrase: String?, + backup: JsonNode + ) { + throw NexusError( + HttpStatusCode.NotImplemented, + "Operation not needed." + ) + } + + override suspend fun connect(client: HttpClient, connId: String) { + /** + * Future versions might create a bank account at this step. + * Right now, all the tests do create those accounts beforehand. + */ + throw NexusError( + HttpStatusCode.NotImplemented, + "Operation not needed." + ) + } +} + diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Helpers.kt @@ -176,13 +176,13 @@ fun wireTransfer( amount: String // $currency:x.y ): String { val args: Triple<BankAccountEntity, BankAccountEntity, DemobankConfigEntity> = transaction { - val debitAccount = BankAccountEntity.find { + val debitAccountDb = BankAccountEntity.find { BankAccountsTable.label eq debitAccount }.firstOrNull() ?: throw SandboxError( HttpStatusCode.NotFound, "Debit account '$debitAccount' not found" ) - val creditAccount = BankAccountEntity.find { + val creditAccountDb = BankAccountEntity.find { BankAccountsTable.label eq creditAccount }.firstOrNull() ?: throw SandboxError( HttpStatusCode.NotFound, @@ -195,7 +195,7 @@ fun wireTransfer( "Demobank '$demobank' not found" ) - Triple(debitAccount, creditAccount, demoBank) + Triple(debitAccountDb, creditAccountDb, demoBank) } /** @@ -228,7 +228,8 @@ fun wireTransfer( subject: String, amount: String, ): String { - + // sanity check on the amount, no currency allowed here. + parseDecimal(amount) val timeStamp = getUTCnow().toInstant().toEpochMilli() val transactionRef = getRandomString(8) transaction { @@ -281,7 +282,6 @@ fun getWithdrawalOperation(opId: String): TalerWithdrawalEntity { fun getBankAccountFromPayto(paytoUri: String): BankAccountEntity { val paytoParse = parsePayto(paytoUri) return getBankAccountFromIban(paytoParse.iban) - } fun getBankAccountFromIban(iban: String): BankAccountEntity { diff --git a/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt b/sandbox/src/main/kotlin/tech/libeufin/sandbox/Main.kt @@ -986,6 +986,39 @@ val sandboxApp: Application.() -> Unit = { route("/demobanks/{demobankid}") { + // NOTE: TWG assumes that username == bank account label. + route("/taler-wire-gateway") { + post("/{exchangeUsername}/admin/add-incoming") { + val username = call.getUriComponent("exchangeUsername") + val usernameAuth = call.request.basicAuth() + if (username != usernameAuth) { + throw forbidden( + "Bank account name and username differ: $username vs $usernameAuth" + ) + } + logger.debug("TWG add-incoming passed authentication") + val body = call.receive<TWGAdminAddIncoming>() + transaction { + val demobank = ensureDemobank(call) + val bankAccountCredit = getBankAccountFromLabel(username, demobank) + if (bankAccountCredit.owner != username) throw forbidden( + "User '$username' cannot access bank account with label: $username." + ) + val bankAccountDebit = getBankAccountFromPayto(body.debit_account) + logger.debug("TWG add-incoming about to wire transfer") + wireTransfer( + bankAccountDebit.label, + bankAccountCredit.label, + demobank.name, + body.reserve_pub, + body.amount + ) + logger.debug("TWG add-incoming has wire transferred") + } + call.respond(object {}) + return@post + } + } // Talk to wallets. route("/integration-api") { @@ -1283,8 +1316,8 @@ val sandboxApp: Application.() -> Unit = { } // Create new customer. requireValidResourceName(req.username) - transaction { - BankAccountEntity.new { + val bankAccount = transaction { + val bankAccount = BankAccountEntity.new { iban = getIban() /** * For now, keep same semantics of Pybank: a username @@ -1299,8 +1332,19 @@ val sandboxApp: Application.() -> Unit = { username = req.username passwordHash = CryptoUtil.hashpw(req.password) } + bankAccount } - call.respond(object {}) + call.respond(object { + val balance = { + val amount = "${demobank.currency}:0" + val credit_debit_indicator = "CRDT" + } + val paytoUri = buildIbanPaytoUri( + iban = bankAccount.iban, + bic = bankAccount.bic, + receiverName = getPersonNameFromCustomer(req.username) + ) + }) return@post } } diff --git a/util/src/main/kotlin/JSON.kt b/util/src/main/kotlin/JSON.kt @@ -59,6 +59,12 @@ data class IncomingPaymentInfo( val subject: String ) +data class TWGAdminAddIncoming( + val amount: String, + val reserve_pub: String, + val debit_account: String +) + data class PaymentInfo( val accountLabel: String, val creditorIban: String, diff --git a/util/src/main/kotlin/Payto.kt b/util/src/main/kotlin/Payto.kt @@ -74,9 +74,9 @@ fun parsePayto(paytoLine: String): Payto { fun buildIbanPaytoUri( iban: String, - bic: String, + bic: String?, receiverName: String, ): String { val nameUrlEnc = URLEncoder.encode(receiverName, "utf-8") - return "payto://iban/$bic/$iban?receiver-name=$nameUrlEnc" + return "payto://iban/${if (bic != null) "$bic/" else ""}$iban?receiver-name=$nameUrlEnc" } \ No newline at end of file diff --git a/util/src/main/kotlin/iban.kt b/util/src/main/kotlin/iban.kt @@ -1,11 +1,12 @@ package tech.libeufin.util +import java.math.BigInteger + fun getIban(): String { - val bankCode = "00000000" // 8 digits - val accountCodeChars = ('0'..'9') - // 10 digits - val accountCode = (0..9).map { - accountCodeChars.random() - }.joinToString("") - return "EU00" + bankCode + accountCode + val ccNoCheck = "131400" // DE00 + val bban = (0..3).map { + (0..9).random() + }.joinToString("") // 4 digits BBAN. + val checkDigits = "98".toBigInteger().minus("$bban$ccNoCheck".toBigInteger().mod("97".toBigInteger())) + return "DE$checkDigits$bban" } \ No newline at end of file diff --git a/util/src/test/kotlin/ibanTest.kt b/util/src/test/kotlin/ibanTest.kt @@ -0,0 +1,10 @@ +import org.junit.Test +import tech.libeufin.util.getIban + +class IbanTest { + + @Test + fun genIban() { + println(getIban()) + } +} +\ No newline at end of file