libeufin

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

commit 1a641ba73be83f379a2e0c9f876b3c6ff30d3ed7
parent 57073eaed927873b2ba0e50f7c7b2b422258071f
Author: Florian Dold <florian@dold.me>
Date:   Sun, 24 Sep 2023 21:18:27 +0200

fix timestamp format, normalize internal IBANs

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt | 21++++++++++++++-------
Mbank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt | 133++++++++++++++++++++++++++++++++++---------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 1-
Mbank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt | 48++++++++++++++++++------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 7++++++-
Mbank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt | 41++++++++++++++++++++++++-----------------
Mbank/src/test/kotlin/LibeuFinApiTest.kt | 40++++++++++++++++++++++++++++++++++++++++
Autil/src/main/kotlin/IbanPayto.kt | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dutil/src/main/kotlin/Payto.kt | 100-------------------------------------------------------------------------------
Mutil/src/test/kotlin/PaytoTest.kt | 4++--
10 files changed, 272 insertions(+), 234 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt @@ -24,6 +24,7 @@ import io.ktor.server.application.* import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import java.util.* +import kotlin.reflect.jvm.internal.impl.types.AbstractStubType /** * Allowed lengths for fractional digits in amounts. @@ -38,9 +39,15 @@ enum class FracDigits(howMany: Int) { * Timestamp containing the number of seconds since epoch. */ @Serializable -data class Timestamp( - val t_s: Long // FIXME (?): not supporting "never" at the moment. -) +data class TalerProtocolTimestamp( + val t_s: Long, // FIXME (?): not supporting "never" at the moment. +) { + companion object { + fun fromMicroseconds(uSec: Long): TalerProtocolTimestamp { + return TalerProtocolTimestamp(uSec / 1000000) + } + } +} /** * HTTP response type of successful token refresh. @@ -51,7 +58,7 @@ data class Timestamp( @Serializable data class TokenSuccessResponse( val access_token: String, - val expiration: Timestamp + val expiration: TalerProtocolTimestamp ) /** @@ -545,7 +552,7 @@ data class AddIncomingRequest( */ @Serializable data class AddIncomingResponse( - val timestamp: Long, + val timestamp: TalerProtocolTimestamp, val row_id: Long ) @@ -572,7 +579,7 @@ data class IncomingHistory( data class IncomingReserveTransaction( val type: String = "RESERVE", val row_id: Long, // DB row ID of the payment. - val date: Long, // microseconds timestamp. + val date: TalerProtocolTimestamp, val amount: String, val debit_account: String, // Payto of the sender. val reserve_pub: String @@ -596,6 +603,6 @@ data class TransferRequest( */ @Serializable data class TransferResponse( - val timestamp: Long, + val timestamp: TalerProtocolTimestamp, val row_id: Long ) \ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt @@ -26,7 +26,8 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { } post("/accounts/{USERNAME}/token") { - val customer = call.authenticateBankRequest(db, TokenScope.refreshable) ?: throw unauthorized("Authentication failed") + val customer = + call.authenticateBankRequest(db, TokenScope.refreshable) ?: throw unauthorized("Authentication failed") val endpointOwner = call.maybeUriComponent("USERNAME") if (customer.login != endpointOwner) throw forbidden( "User has no rights on this enpoint", TalerErrorCode.TALER_EC_END // FIXME: need generic forbidden @@ -51,8 +52,7 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { } val maxDurationTime: Long = ctx.maxAuthTokenDurationUs if (req.duration != null && req.duration.d_us > maxDurationTime) throw forbidden( - "Token duration bigger than bank's limit", - // FIXME: define new EC for this case. + "Token duration bigger than bank's limit", // FIXME: define new EC for this case. TalerErrorCode.TALER_EC_END ) val tokenDurationUs = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION_US @@ -61,8 +61,7 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { ) val expirationTimestampUs: Long = getNowUs() + tokenDurationUs if (expirationTimestampUs < tokenDurationUs) throw badRequest( - "Token duration caused arithmetic overflow", - // FIXME: need dedicate EC (?) + "Token duration caused arithmetic overflow", // FIXME: need dedicate EC (?) talerErrorCode = TalerErrorCode.TALER_EC_END ) val token = BearerToken( @@ -76,7 +75,7 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { if (!db.bearerTokenCreate(token)) throw internalServerError("Failed at inserting new token in the database") call.respond( TokenSuccessResponse( - access_token = Base32Crockford.encode(tokenBytes), expiration = Timestamp( + access_token = Base32Crockford.encode(tokenBytes), expiration = TalerProtocolTimestamp( t_s = expirationTimestampUs / 1000000L ) ) @@ -84,8 +83,7 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { return@post } - post("/accounts") { - // check if only admin is allowed to create new accounts + post("/accounts") { // check if only admin is allowed to create new accounts if (ctx.restrictRegistration) { val customer: Customer? = call.authenticateBankRequest(db, TokenScope.readwrite) if (customer == null || customer.login != "admin") throw LibeufinBankException( @@ -94,30 +92,29 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { hint = "Either 'admin' not authenticated or an ordinary user tried this operation." ) ) - } - // auth passed, proceed with activity. - val req = call.receive<RegisterAccountRequest>() - // Prohibit reserved usernames: + } // auth passed, proceed with activity. + val req = call.receive<RegisterAccountRequest>() // Prohibit reserved usernames: if (req.username == "admin" || req.username == "bank") throw LibeufinBankException( httpStatus = HttpStatusCode.Conflict, talerError = TalerError( code = GENERIC_UNDEFINED, // FIXME: this waits GANA. hint = "Username '${req.username}' is reserved." ) - ) - // Checking idempotency. - val maybeCustomerExists = db.customerGetFromLogin(req.username) - // Can be null if previous call crashed before completion. + ) // Checking idempotency. + val maybeCustomerExists = + db.customerGetFromLogin(req.username) // Can be null if previous call crashed before completion. val maybeHasBankAccount = maybeCustomerExists.run { if (this == null) return@run null db.bankAccountGetFromOwnerId(this.expectRowId()) } if (maybeCustomerExists != null && maybeHasBankAccount != null) { - logger.debug("Registering username was found: ${maybeCustomerExists.login}") - // Checking _all_ the details are the same. + logger.debug("Registering username was found: ${maybeCustomerExists.login}") // Checking _all_ the details are the same. val isIdentic = - maybeCustomerExists.name == req.name && maybeCustomerExists.email == req.challenge_contact_data?.email && maybeCustomerExists.phone == req.challenge_contact_data?.phone && maybeCustomerExists.cashoutPayto == req.cashout_payto_uri && CryptoUtil.checkpw( - req.password, - maybeCustomerExists.passwordHash + maybeCustomerExists.name == req.name && + maybeCustomerExists.email == req.challenge_contact_data?.email && + maybeCustomerExists.phone == req.challenge_contact_data?.phone && + maybeCustomerExists.cashoutPayto == + req.cashout_payto_uri && CryptoUtil.checkpw( + req.password, maybeCustomerExists.passwordHash ) && maybeHasBankAccount.isPublic == req.is_public && maybeHasBankAccount.isTalerExchange == req.is_taler_exchange && maybeHasBankAccount.internalPaytoUri == req.internal_payto_uri if (isIdentic) { call.respond(HttpStatusCode.Created) @@ -129,26 +126,27 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { hint = "Idempotency check failed." ) ) - } - // From here: fresh user being added. + } // From here: fresh user being added. val newCustomer = Customer( login = req.username, name = req.name, email = req.challenge_contact_data?.email, phone = req.challenge_contact_data?.phone, - cashoutPayto = req.cashout_payto_uri, - // Following could be gone, if included in cashout_payto_uri + cashoutPayto = req.cashout_payto_uri, // Following could be gone, if included in cashout_payto_uri cashoutCurrency = ctx.cashoutCurrency, passwordHash = CryptoUtil.hashpw(req.password), ) val newCustomerRowId = db.customerCreate(newCustomer) - ?: throw internalServerError("New customer INSERT failed despite the previous checks")/* Crashing here won't break data consistency between customers - * and bank accounts, because of the idempotency. Client will - * just have to retry. */ + ?: throw internalServerError("New customer INSERT failed despite the previous checks") // Crashing here won't break data consistency between customers // and bank accounts, because of the idempotency. Client will // just have to retry. val maxDebt = ctx.defaultCustomerDebtLimit + val internalPayto: String = if (req.internal_payto_uri != null) { + stripIbanPayto(req.internal_payto_uri) + } else { + stripIbanPayto(genIbanPaytoUri()) + } val newBankAccount = BankAccount( hasDebt = false, - internalPaytoUri = req.internal_payto_uri ?: genIbanPaytoUri(), + internalPaytoUri = internalPayto, owningCustomerId = newCustomerRowId, isPublic = req.is_public, isTalerExchange = req.is_taler_exchange, @@ -157,12 +155,9 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { val newBankAccountId = db.bankAccountCreate(newBankAccount) ?: throw internalServerError("Could not INSERT bank account despite all the checks.") - /** - * The new account got created, now optionally award the registration - * bonus to it. The configuration gets either a Taler amount (of the - * bonus), or null if no bonus is meant to be awarded. - */ - val bonusAmount = if (ctx.registrationBonusEnabled) ctx.registrationBonus else null + // The new account got created, now optionally award the registration + // bonus to it. + val bonusAmount = if (ctx.registrationBonusEnabled && !req.is_taler_exchange) ctx.registrationBonus else null if (bonusAmount != null) { val adminCustomer = db.customerGetFromLogin("admin") ?: throw internalServerError("Admin customer not found") @@ -194,9 +189,7 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized("Login failed") val resourceName = call.maybeUriComponent("USERNAME") ?: throw badRequest( hint = "No username found in the URI", talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_PARAMETER_MISSING - ) - // Checking resource name only if Basic auth was used. - // Successful tokens do not need this check, they just pass. + ) // Checking resource name only if Basic auth was used. // Successful tokens do not need this check, they just pass. if (((c.login != resourceName) && (c.login != "admin")) && (call.getAuthToken() == null)) throw forbidden("No rights on the resource.") val customerData = db.customerGetFromLogin(c.login) ?: throw internalServerError("Customer '${c.login} despite being authenticated.'") @@ -205,8 +198,11 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { val bankAccountData = db.bankAccountGetFromOwnerId(customerInternalId) ?: throw internalServerError("Customer '${c.login} had no bank account despite they are customer.'") val balance = Balance( - amount = bankAccountData.balance.toString(), - credit_debit_indicator = if (bankAccountData.hasDebt) { "debit" } else { "credit" } + amount = bankAccountData.balance.toString(), credit_debit_indicator = if (bankAccountData.hasDebt) { + "debit" + } else { + "credit" + } ) call.respond( AccountData( @@ -224,12 +220,11 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { } post("/accounts/{USERNAME}/withdrawals") { - val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() - // Admin not allowed to withdraw in the name of customers: + val c = call.authenticateBankRequest(db, TokenScope.readwrite) + ?: throw unauthorized() // Admin not allowed to withdraw in the name of customers: val accountName = call.expectUriComponent("USERNAME") if (c.login != accountName) throw unauthorized("User ${c.login} not allowed to withdraw for account '${accountName}'") - val req = call.receive<BankAccountCreateWithdrawalRequest>() - // Checking that the user has enough funds. + val req = call.receive<BankAccountCreateWithdrawalRequest>() // Checking that the user has enough funds. val b = db.bankAccountGetFromOwnerId(c.expectRowId()) ?: throw internalServerError("Customer '${c.login}' lacks bank account.") val withdrawalAmount = parseTalerAmount(req.amount) @@ -239,8 +234,7 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { ) throw forbidden( hint = "Insufficient funds to withdraw with Taler", talerErrorCode = TalerErrorCode.TALER_EC_NONE // FIXME: need EC. - ) - // Auth and funds passed, create the operation now! + ) // Auth and funds passed, create the operation now! val opId = UUID.randomUUID() if (!db.talerWithdrawalCreate( opId, b.expectRowId(), withdrawalAmount @@ -272,13 +266,11 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { } post("/withdrawals/{withdrawal_id}/abort") { - val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) - // Idempotency: + val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Idempotency: if (op.aborted) { call.respondText("{}", ContentType.Application.Json) return@post - } - // Op is found, it'll now fail only if previously confirmed (DB checks). + } // Op is found, it'll now fail only if previously confirmed (DB checks). if (!db.talerWithdrawalAbort(op.withdrawalUuid)) throw conflict( hint = "Cannot abort confirmed withdrawal", talerEc = TalerErrorCode.TALER_EC_END ) @@ -287,25 +279,22 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { } post("/withdrawals/{withdrawal_id}/confirm") { - val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) - // Checking idempotency: + val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) // Checking idempotency: if (op.confirmationDone) { call.respondText("{}", ContentType.Application.Json) return@post } if (op.aborted) throw conflict( hint = "Cannot confirm an aborted withdrawal", talerEc = TalerErrorCode.TALER_EC_BANK_CONFIRM_ABORT_CONFLICT - ) - // Checking that reserve GOT indeed selected. + ) // Checking that reserve GOT indeed selected. if (!op.selectionDone) throw LibeufinBankException( httpStatus = HttpStatusCode.UnprocessableEntity, talerError = TalerError( hint = "Cannot confirm an unselected withdrawal", code = TalerErrorCode.TALER_EC_END.code ) - )/* Confirmation conditions are all met, now put the operation - * to the selected state _and_ wire the funds to the exchange. - * Note: 'when' helps not to omit more result codes, should more - * be added. - */ + ) // Confirmation conditions are all met, now put the operation + // to the selected state _and_ wire the funds to the exchange. + // Note: 'when' helps not to omit more result codes, should more + // be added. when (db.talerWithdrawalConfirm(op.withdrawalUuid, getNowUs())) { WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> throw conflict( "Insufficient funds", TalerErrorCode.TALER_EC_END // FIXME: define EC for this. @@ -340,10 +329,8 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { get("/accounts/{USERNAME}/transactions") { val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() val resourceName = call.expectUriComponent("USERNAME") - if (c.login != resourceName && c.login != "admin") throw forbidden() - // Collecting params. - val historyParams = getHistoryParams(call.request) - // Making the query. + if (c.login != resourceName && c.login != "admin") throw forbidden() // Collecting params. + val historyParams = getHistoryParams(call.request) // Making the query. val bankAccount = db.bankAccountGetFromOwnerId(c.expectRowId()) ?: throw internalServerError("Customer '${c.login}' lacks bank account.") val bankAccountId = bankAccount.expectRowId() @@ -373,17 +360,14 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { // Creates a bank transaction. post("/accounts/{USERNAME}/transactions") { val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() - val resourceName = call.expectUriComponent("USERNAME") - // admin has no rights here. + val resourceName = call.expectUriComponent("USERNAME") // admin has no rights here. if ((c.login != resourceName) && (call.getAuthToken() == null)) throw forbidden() val txData = call.receive<BankAccountTransactionCreate>() - // FIXME: make payto parser IBAN-agnostic? val payto = parsePayto(txData.payto_uri) ?: throw badRequest("Invalid creditor Payto") - val paytoWithoutParams = "payto://iban/${payto.bic}/${payto.iban}" + val paytoWithoutParams = stripIbanPayto(txData.payto_uri) val subject = payto.message ?: throw badRequest("Wire transfer lacks subject") - val debtorId = c.dbRowId ?: throw internalServerError("Debtor database ID not found") - // This performs already a SELECT on the bank account, - // like the wire transfer will do as well later! + val debtorId = c.dbRowId + ?: throw internalServerError("Debtor database ID not found") // This performs already a SELECT on the bank account, // like the wire transfer will do as well later! val creditorCustomerData = db.bankAccountGetFromInternalPayto(paytoWithoutParams) ?: throw notFound( "Creditor account not found", TalerErrorCode.TALER_EC_END // FIXME: define this EC. ) @@ -415,10 +399,8 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { get("/accounts/{USERNAME}/transactions/{T_ID}") { val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() - val accountOwner = call.expectUriComponent("USERNAME") - // auth ok, check rights. - if (c.login != "admin" && c.login != accountOwner) throw forbidden() - // rights ok, check tx exists. + val accountOwner = call.expectUriComponent("USERNAME") // auth ok, check rights. + if (c.login != "admin" && c.login != accountOwner) throw forbidden() // rights ok, check tx exists. val tId = call.expectUriComponent("T_ID") val txRowId = try { tId.toLong() @@ -432,8 +414,7 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { ) val customerBankAccount = db.bankAccountGetFromOwnerId(customerRowId) ?: throw internalServerError("Customer '${c.login}' lacks bank account.") - if (tx.bankAccountId != customerBankAccount.bankAccountId) throw forbidden("Client has no rights over the bank transaction: $tId") - // auth and rights, respond. + if (tx.bankAccountId != customerBankAccount.bankAccountId) throw forbidden("Client has no rights over the bank transaction: $tId") // auth and rights, respond. call.respond( BankAccountTransactionInfo( amount = "${tx.amount.currency}:${tx.amount.value}.${tx.amount.frac}", diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -34,7 +34,6 @@ import kotlin.math.abs private const val DB_CTR_LIMIT = 1000000 - fun Customer.expectRowId(): Long = this.dbRowId ?: throw internalServerError("Cutsomer '$login' had no DB row ID.") fun BankAccount.expectBalance(): TalerAmount = this.balance ?: throw internalServerError("Bank account '${this.internalPaytoUri}' lacks balance.") fun BankAccount.expectRowId(): Long = this.bankAccountId ?: throw internalServerError("Bank account '${this.internalPaytoUri}' lacks database row ID.") diff --git a/bank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt @@ -27,20 +27,19 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import net.taler.common.errorcodes.TalerErrorCode import tech.libeufin.util.getBaseUrl +import tech.libeufin.util.stripIbanPayto fun Routing.talerIntegrationHandlers(db: Database, ctx: BankApplicationContext) { get("/taler-integration/config") { val internalCurrency: String = ctx.currency call.respond(TalerIntegrationConfigResponse(currency = internalCurrency)) return@get - } - // Note: wopid acts as an authentication token. + } // Note: wopid acts as an authentication token. get("/taler-integration/withdrawal-operation/{wopid}") { val wopid = call.expectUriComponent("wopid") val op = getWithdrawal(db, wopid) // throws 404 if not found. val relatedBankAccount = db.bankAccountGetFromOwnerId(op.walletBankAccount) - if (relatedBankAccount == null) - throw internalServerError("Bank has a withdrawal not related to any bank account.") + if (relatedBankAccount == null) throw internalServerError("Bank has a withdrawal not related to any bank account.") val suggestedExchange = ctx.suggestedWithdrawalExchange val walletCustomer = db.customerGetFromRowId(relatedBankAccount.owningCustomerId) if (walletCustomer == null) @@ -65,31 +64,23 @@ fun Routing.talerIntegrationHandlers(db: Database, ctx: BankApplicationContext) val wopid = call.expectUriComponent("wopid") val req = call.receive<BankWithdrawalOperationPostRequest>() val op = getWithdrawal(db, wopid) // throws 404 if not found. - if (op.selectionDone) { - // idempotency - if (op.selectedExchangePayto != req.selected_exchange && - op.reservePub != req.reserve_pub) - throw conflict( - hint = "Cannot select different exchange and reserve pub. under the same withdrawal operation", - talerEc = TalerErrorCode.TALER_EC_BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT - ) + if (op.selectionDone) { // idempotency + if (op.selectedExchangePayto != req.selected_exchange && op.reservePub != req.reserve_pub) throw conflict( + hint = "Cannot select different exchange and reserve pub. under the same withdrawal operation", + talerEc = TalerErrorCode.TALER_EC_BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT + ) } - val dbSuccess: Boolean = if (!op.selectionDone) { - // Check if reserve pub. was used in _another_ withdrawal. - if (db.bankTransactionCheckExists(req.reserve_pub) != null) - throw conflict( - "Reserve pub. already used", - TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT - ) - val exchangePayto = req.selected_exchange + val dbSuccess: Boolean = if (!op.selectionDone) { // Check if reserve pub. was used in _another_ withdrawal. + if (db.bankTransactionCheckExists(req.reserve_pub) != null) throw conflict( + "Reserve pub. already used", TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT + ) + val exchangePayto = stripIbanPayto(req.selected_exchange) db.talerWithdrawalSetDetails( - op.withdrawalUuid, - exchangePayto, - req.reserve_pub + op.withdrawalUuid, exchangePayto, req.reserve_pub ) - } - else // DB succeeded in the past. + } else { // Nothing to do in the database, i.e. we were successful true + } if (!dbSuccess) // Whatever the problem, the bank missed it: respond 500. throw internalServerError("Bank failed at selecting the withdrawal.") @@ -99,12 +90,9 @@ fun Routing.talerIntegrationHandlers(db: Database, ctx: BankApplicationContext) baseUrl = ctx.spaCaptchaURL, wopId = wopid ) - } - else - null + } else null val resp = BankWithdrawalOperationPostResponse( - transfer_done = op.confirmationDone, - confirm_transfer_url = confirmUrl + transfer_done = op.confirmationDone, confirm_transfer_url = confirmUrl ) call.respond(resp) return@post diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -435,7 +435,12 @@ class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") val db = Database(dbConnStr, ctx.currency) if (!maybeCreateAdminAccount(db, ctx)) // logs provided by the helper exitProcess(1) - embeddedServer(Netty, port = servePort) { + embeddedServer(Netty, port = servePort, configure = { + // Disable threads for now, the DB isn't thread safe yet. + connectionGroupSize = 1 + workerGroupSize = 1 + callGroupSize = 1 + }) { corebankWebApp(db, ctx) }.start(wait = true) } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt @@ -56,13 +56,15 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) } val resp = IncomingHistory(credit_account = bankAccount.internalPaytoUri) history.forEach { - resp.incoming_transactions.add(IncomingReserveTransaction( - row_id = it.expectRowId(), - amount = it.amount.toString(), - date = it.transactionDate, - debit_account = it.debtorPaytoUri, - reserve_pub = it.subject - )) + resp.incoming_transactions.add( + IncomingReserveTransaction( + row_id = it.expectRowId(), + amount = it.amount.toString(), + date = TalerProtocolTimestamp.fromMicroseconds(it.transactionDate), + debit_account = it.debtorPaytoUri, + reserve_pub = it.subject + ) + ) } call.respond(resp) return@get @@ -81,10 +83,12 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) && maybeDoneAlready.exchangeBaseUrl == req.exchange_base_url && maybeDoneAlready.wtid == req.wtid if (isIdempotent) { - call.respond(TransferResponse( - timestamp = maybeDoneAlready.timestamp, - row_id = maybeDoneAlready.debitTxRowId - )) + call.respond( + TransferResponse( + timestamp = TalerProtocolTimestamp.fromMicroseconds(maybeDoneAlready.timestamp), + row_id = maybeDoneAlready.debitTxRowId + ) + ) return@post } throw conflict( @@ -116,10 +120,12 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) ) val debitRowId = dbRes.txRowId ?: throw internalServerError("Database did not return the debit tx row ID") - call.respond(TransferResponse( - timestamp = transferTimestamp, - row_id = debitRowId - )) + call.respond( + TransferResponse( + timestamp = TalerProtocolTimestamp.fromMicroseconds(transferTimestamp), + row_id = debitRowId + ) + ) return@post } @@ -169,8 +175,9 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) call.respond( AddIncomingResponse( row_id = rowId, - timestamp = txTimestamp - )) + timestamp = TalerProtocolTimestamp.fromMicroseconds(txTimestamp) + ) + ) return@post } } diff --git a/bank/src/test/kotlin/LibeuFinApiTest.kt b/bank/src/test/kotlin/LibeuFinApiTest.kt @@ -291,6 +291,46 @@ class LibeuFinApiTest { } /** + * Testing the account creation and its idempotency + */ + @Test + fun createTwoAccountsTest() { + testApplication { + val db = initDb() + val ctx = getTestContext() + val ibanPayto = genIbanPaytoUri() + application { + corebankWebApp(db, ctx) + } + var resp = client.post("/accounts") { + expectSuccess = false + contentType(ContentType.Application.Json) + setBody( + """{ + "username": "foo", + "password": "bar", + "name": "Jane" + }""".trimIndent() + ) + } + assert(resp.status == HttpStatusCode.Created) + // Test creating another account. + resp = client.post("/accounts") { + expectSuccess = false + contentType(ContentType.Application.Json) + setBody( + """{ + "username": "joe", + "password": "bar", + "name": "Joe" + }""".trimIndent() + ) + } + assert(resp.status == HttpStatusCode.Created) + } + } + + /** * Test admin-only account creation */ @Test diff --git a/util/src/main/kotlin/IbanPayto.kt b/util/src/main/kotlin/IbanPayto.kt @@ -0,0 +1,111 @@ +package tech.libeufin.util + +import logger +import java.net.URI +import java.net.URLDecoder +import java.net.URLEncoder + +// Payto information. +data class IbanPayto( + // represent query param "sender-name" or "receiver-name". + val receiverName: String?, + val iban: String, + val bic: String?, + // Typically, a wire transfer's subject. + val message: String?, + val amount: String? +) + +// 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. +private fun getQueryParamOrNull(name: String, params: List<Pair<String, String>>?): String? { + if (params == null) return null + return params.firstNotNullOfOrNull { pair -> + URLDecoder.decode(pair.second, Charsets.UTF_8).takeIf { pair.first == name } + } +} + +// Parses a Payto URI, returning null if the input is invalid. +fun parsePayto(payto: String): IbanPayto? { + /** + * This check is due because URIs having a "payto:" prefix without + * slashes are correctly parsed by the Java 'URI' class. 'mailto' + * for example lacks the double-slash part. + */ + if (!payto.startsWith("payto://")) { + logger.error("Invalid payto URI: $payto") + return null + } + + val javaParsedUri = try { + URI(payto) + } catch (e: java.lang.Exception) { + logger.error("'${payto}' is not a valid URI") + return null + } + if (javaParsedUri.scheme != "payto") { + logger.error("'${payto}' is not payto") + return null + } + val wireMethod = javaParsedUri.host + if (wireMethod != "iban") { + logger.error("Only 'iban' is supported, not '$wireMethod'") + return null + } + val splitPath = javaParsedUri.path.split("/").filter { it.isNotEmpty() } + if (splitPath.size > 2) { + logger.error("too many path segments in iban payto URI: $payto") + return null + } + val (iban, bic) = if (splitPath.size == 1) { + Pair(splitPath[0], null) + } else Pair(splitPath[1], splitPath[0]) + + val params: List<Pair<String, String>>? = if (javaParsedUri.query != null) { + val queryString: List<String> = javaParsedUri.query.split("&") + queryString.map { + val split = it.split("="); + if (split.size != 2) { + logger.error("parameter '$it' was malformed") + return null + } + Pair(split[0], split[1]) + } + } else null + + return IbanPayto( + iban = iban, + bic = bic, + amount = getQueryParamOrNull("amount", params), + message = getQueryParamOrNull("message", params), + receiverName = getQueryParamOrNull("receiver-name", params) + ) +} + +fun buildIbanPaytoUri( + iban: String, + bic: String, + receiverName: String, + message: String? = null +): String { + val nameUrlEnc = URLEncoder.encode(receiverName, "utf-8") + val ret = "payto://iban/$bic/$iban?receiver-name=$nameUrlEnc" + if (message != null) { + val messageUrlEnc = URLEncoder.encode(message, "utf-8") + return "$ret&message=$messageUrlEnc" + } + return ret +} + +/** + * Strip a payto://iban URI of everything + * except the IBAN. + */ +fun stripIbanPayto(paytoUri: String): String { + val parsedPayto = parsePayto(paytoUri) + if (parsedPayto == null) { + throw Error("invalid payto://iban URI") + } + val canonIban = parsedPayto.iban.lowercase() + return "payto://iban/${canonIban}" +} diff --git a/util/src/main/kotlin/Payto.kt b/util/src/main/kotlin/Payto.kt @@ -1,99 +0,0 @@ -package tech.libeufin.util - -import io.ktor.http.* -import logger -import java.net.URI -import java.net.URLDecoder -import java.net.URLEncoder - -// Payto information. -data class Payto( - // represent query param "sender-name" or "receiver-name". - val receiverName: String?, - val iban: String, - val bic: String?, - // Typically, a wire transfer's subject. - val message: String?, - val amount: String? -) - -// 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. -private fun getQueryParamOrNull(name: String, params: List<Pair<String, String>>?): String? { - if (params == null) return null - return params.firstNotNullOfOrNull { pair -> - URLDecoder.decode(pair.second, Charsets.UTF_8).takeIf { pair.first == name } - } -} - -// Parses a Payto URI, returning null if the input is invalid. -fun parsePayto(payto: String): Payto? { - /** - * This check is due because URIs having a "payto:" prefix without - * slashes are correctly parsed by the Java 'URI' class. 'mailto' - * for example lacks the double-slash part. - */ - if (!payto.startsWith("payto://")) { - logger.error("Invalid payto URI: $payto") - return null - } - - val javaParsedUri = try { - URI(payto) - } catch (e: java.lang.Exception) { - logger.error("'${payto}' is not a valid URI") - return null - } - if (javaParsedUri.scheme != "payto") { - logger.error("'${payto}' is not payto") - return null - } - val wireMethod = javaParsedUri.host - if (wireMethod != "iban") { - logger.error("Only 'iban' is supported, not '$wireMethod'") - return null - } - val splitPath = javaParsedUri.path.split("/").filter { it.isNotEmpty() } - if (splitPath.size > 2) { - logger.error("too many path segments in iban payto URI: $payto") - return null - } - val (iban, bic) = if (splitPath.size == 1) { - Pair(splitPath[0], null) - } else Pair(splitPath[1], splitPath[0]) - - val params: List<Pair<String, String>>? = if (javaParsedUri.query != null) { - val queryString: List<String> = javaParsedUri.query.split("&") - queryString.map { - val split = it.split("="); - if (split.size != 2) { - logger.error("parameter '$it' was malformed") - return null - } - Pair(split[0], split[1]) - } - } else null - - return Payto( - iban = iban, - bic = bic, - amount = getQueryParamOrNull("amount", params), - message = getQueryParamOrNull("message", params), - receiverName = getQueryParamOrNull("receiver-name", params) - ) -} - -fun buildIbanPaytoUri( - iban: String, - bic: String, - receiverName: String, - message: String? = null -): String { - val nameUrlEnc = URLEncoder.encode(receiverName, "utf-8") - val ret = "payto://iban/$bic/$iban?receiver-name=$nameUrlEnc" - if (message != null) { - val messageUrlEnc = URLEncoder.encode(message, "utf-8") - return "$ret&message=$messageUrlEnc" - } - return ret -} -\ No newline at end of file diff --git a/util/src/test/kotlin/PaytoTest.kt b/util/src/test/kotlin/PaytoTest.kt @@ -1,5 +1,5 @@ import org.junit.Test -import tech.libeufin.util.Payto +import tech.libeufin.util.IbanPayto import tech.libeufin.util.parsePayto class PaytoTest { @@ -13,7 +13,7 @@ class PaytoTest { @Test fun parsePaytoTest() { - val withBic: Payto = parsePayto("payto://iban/BIC123/IBAN123?receiver-name=The%20Name")!! + val withBic: IbanPayto = parsePayto("payto://iban/BIC123/IBAN123?receiver-name=The%20Name")!! assert(withBic.iban == "IBAN123") assert(withBic.bic == "BIC123") assert(withBic.receiverName == "The Name")