libeufin

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

commit e900f224cb9c8e5046b8bbb9db46f96cb40c3b9c
parent db76884dee5897197ef0f3a0d8f72c58ea7e7723
Author: Antoine A <>
Date:   Mon, 16 Oct 2023 10:48:56 +0000

Cleanup and improve tests, fix procedures.sql

Diffstat:
Abank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt | 596+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dbank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt | 595-------------------------------------------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 45++++++++++-----------------------------------
Dbank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt | 102-------------------------------------------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 6+++---
Mbank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt | 46+++++++++++-----------------------------------
Abank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dbank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt | 197-------------------------------------------------------------------------------
Abank/src/test/kotlin/BankIntegrationApiTest.kt | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dbank/src/test/kotlin/TalerApiTest.kt | 752-------------------------------------------------------------------------------
Abank/src/test/kotlin/WireGatewayApiTest.kt | 530+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/test/kotlin/helpers.kt | 54+++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mdatabase-versioning/procedures.sql | 4++--
14 files changed, 1650 insertions(+), 1722 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt @@ -0,0 +1,101 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2019 Stanisci and Dold. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +/* This file contains the Taler Integration API endpoints, +* that are typically requested by wallets. */ +package tech.libeufin.bank + +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import net.taler.common.errorcodes.TalerErrorCode + +fun Routing.bankIntegrationApi(db: Database, ctx: BankApplicationContext) { + get("/taler-integration/config") { + val internalCurrency: String = ctx.currency + call.respond(TalerIntegrationConfigResponse( + currency = internalCurrency, + currency_specification = ctx.currencySpecification + )) + return@get + } + + // 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) + ?: throw internalServerError("Bank has a withdrawal not related to any bank account.") + val suggestedExchange = ctx.suggestedWithdrawalExchange + val confirmUrl = if (ctx.spaCaptchaURL == null) null else + getWithdrawalConfirmUrl( + baseUrl = ctx.spaCaptchaURL, + wopId = wopid + ) + call.respond( + BankWithdrawalOperationStatus( + aborted = op.aborted, + selection_done = op.selectionDone, + transfer_done = op.confirmationDone, + amount = op.amount, + sender_wire = relatedBankAccount.internalPaytoUri.canonical, + suggested_exchange = suggestedExchange, + confirm_transfer_url = confirmUrl + ) + ) + return@get + } + post("/taler-integration/withdrawal-operation/{wopid}") { + 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 + ) + } + 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 + ) + db.talerWithdrawalSetDetails( + op.withdrawalUuid, req.selected_exchange, req.reserve_pub + ) + } 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.") + // Getting user details that MIGHT be used later. + val confirmUrl: String? = if (ctx.spaCaptchaURL !== null && !op.confirmationDone) { + getWithdrawalConfirmUrl( + baseUrl = ctx.spaCaptchaURL, + wopId = wopid + ) + } else null + val resp = BankWithdrawalOperationPostResponse( + transfer_done = op.confirmationDone, confirm_transfer_url = confirmUrl + ) + call.respond(resp) + return@post + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/CoreBankApi.kt @@ -0,0 +1,595 @@ +package tech.libeufin.bank + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import net.taler.common.errorcodes.TalerErrorCode +import net.taler.wallet.crypto.Base32Crockford +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import tech.libeufin.util.* +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") + +/** + * This function collects all the /accounts handlers that + * create, update, delete, show bank accounts. No histories + * and wire transfers should belong here. + */ +fun Routing.accountsMgmtApi(db: Database, ctx: BankApplicationContext) { + + // TOKEN ENDPOINTS + delete("/accounts/{USERNAME}/token") { + val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() + val token = call.getAuthToken() ?: throw badRequest("Basic auth not supported here.") + val resourceName = call.getResourceName("USERNAME") + /** + * The following check makes sure that the token belongs + * to the username contained in {USERNAME}. + */ + if (!resourceName.canI(c, withAdmin = true)) throw forbidden() + + /** + * Not sanity-checking the token, as it was used by the authentication already. + * If harder errors happen, then they'll get Ktor respond with 500. + */ + db.bearerTokenDelete(Base32Crockford.decode(token)) + /** + * Responding 204 regardless of it being actually deleted or not. + * If it wasn't found, then it must have been deleted before we + * reached here, but the token was valid as it served the authentication + * => no reason to fail the request. + */ + call.respond(HttpStatusCode.NoContent) + } + post("/accounts/{USERNAME}/token") { + 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_GENERIC_FORBIDDEN + ) + val maybeAuthToken = call.getAuthToken() + val req = call.receive<TokenRequest>() + /** + * This block checks permissions ONLY IF the call was authenticated + * with a token. Basic auth gets always granted. + */ + if (maybeAuthToken != null) { + val tokenBytes = Base32Crockford.decode(maybeAuthToken) + val refreshingToken = db.bearerTokenGet(tokenBytes) ?: throw internalServerError( + "Token used to auth not found in the database!" + ) + if (refreshingToken.scope == TokenScope.readonly && req.scope == TokenScope.readwrite) throw forbidden( + "Cannot generate RW token from RO", TalerErrorCode.TALER_EC_GENERIC_TOKEN_PERMISSION_INSUFFICIENT + ) + } + val tokenBytes = ByteArray(32).apply { + Random.nextBytes(this) + } + val tokenDuration: Duration = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION + + val creationTime = Instant.now() + val expirationTimestamp = if (tokenDuration == ChronoUnit.FOREVER.duration) { + logger.debug("Creating 'forever' token.") + Instant.MAX + } else { + try { + logger.debug("Creating token with days duration: ${tokenDuration.toDays()}") + creationTime.plus(tokenDuration) + } catch (e: Exception) { + logger.error("Could not add token duration to current time: ${e.message}") + throw badRequest("Bad token duration: ${e.message}") + } + } + val customerDbRow = customer.dbRowId ?: throw internalServerError( + "Could not get customer '${customer.login}' database row ID" + ) + val token = BearerToken( + bankCustomer = customerDbRow, + content = tokenBytes, + creationTime = creationTime, + expirationTime = expirationTimestamp, + scope = req.scope, + isRefreshable = req.refreshable + ) + if (!db.bearerTokenCreate(token)) + throw internalServerError("Failed at inserting new token in the database") + call.respond( + TokenSuccessResponse( + access_token = Base32Crockford.encode(tokenBytes), expiration = TalerProtocolTimestamp( + t_s = expirationTimestamp + ) + ) + ) + return@post + } + // ACCOUNT ENDPOINTS + get("/public-accounts") { + // no authentication here. + val publicAccounts = db.accountsGetPublic(ctx.currency) + if (publicAccounts.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + return@get + } + call.respond( + PublicAccountsResponse().apply { + publicAccounts.forEach { + this.public_accounts.add(it) + } + } + ) + return@get + } + get("/accounts") { + val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() + if (c.login != "admin") throw forbidden("Only admin allowed.") + // Get optional param. + val maybeFilter: String? = call.request.queryParameters["filter_name"] + logger.debug("Filtering on '${maybeFilter}'") + val queryParam = if (maybeFilter != null) { + "%${maybeFilter}%" + } else "%" + val dbRes = db.accountsGetForAdmin(queryParam) + if (dbRes.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + return@get + } + call.respond( + ListBankAccountsResponse().apply { + dbRes.forEach { element -> + this.accounts.add(element) + } + } + ) + return@get + } + 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( + httpStatus = HttpStatusCode.Unauthorized, + talerError = TalerError( + code = TalerErrorCode.TALER_EC_GENERIC_UNAUTHORIZED.code, + 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: + if (req.username == "admin" || req.username == "bank") throw LibeufinBankException( + httpStatus = HttpStatusCode.Conflict, + talerError = TalerError( + code = TalerErrorCode.TALER_EC_BANK_RESERVED_USERNAME_CONFLICT.code, + hint = "Username '${req.username}' is reserved." + ) + ) // 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()) + } + val internalPayto = req.internal_payto_uri ?: IbanPayTo(genIbanPaytoUri()) + if (maybeCustomerExists != null && maybeHasBankAccount != null) { + 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) && + maybeHasBankAccount.isPublic == req.is_public && + maybeHasBankAccount.isTalerExchange == req.is_taler_exchange && + maybeHasBankAccount.internalPaytoUri.canonical == internalPayto.canonical + if (isIdentic) { + call.respond(HttpStatusCode.Created) + return@post + } + throw LibeufinBankException( + httpStatus = HttpStatusCode.Conflict, talerError = TalerError( + code = GENERIC_UNDEFINED, // FIXME: provide appropriate EC. + hint = "Idempotency check failed." + ) + ) + } + + // 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 + 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. + val maxDebt = ctx.defaultCustomerDebtLimit + val newBankAccount = BankAccount( + hasDebt = false, + internalPaytoUri = internalPayto, + owningCustomerId = newCustomerRowId, + isPublic = req.is_public, + isTalerExchange = req.is_taler_exchange, + maxDebt = maxDebt + ) + 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. + 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") + val adminBankAccount = db.bankAccountGetFromOwnerId(adminCustomer.expectRowId()) + ?: throw internalServerError("Admin bank account not found") + val adminPaysBonus = BankInternalTransaction( + creditorAccountId = newBankAccountId, + debtorAccountId = adminBankAccount.expectRowId(), + amount = bonusAmount, + subject = "Registration bonus.", + transactionDate = Instant.now() + ) + when (db.bankTransactionCreate(adminPaysBonus)) { + BankTransactionResult.NO_CREDITOR -> throw internalServerError("Bonus impossible: creditor not found, despite its recent creation.") + BankTransactionResult.NO_DEBTOR -> throw internalServerError("Bonus impossible: admin not found.") + BankTransactionResult.BALANCE_INSUFFICIENT -> throw internalServerError("Bonus impossible: admin has insufficient balance.") + BankTransactionResult.SAME_ACCOUNT -> throw internalServerError("Bonus impossible: admin should not be creditor.") + BankTransactionResult.SUCCESS -> {/* continue the execution */ + } + } + } + call.respond(HttpStatusCode.Created) + return@post + } + get("/accounts/{USERNAME}") { + val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized("Login failed") + val resourceName = call.expectUriComponent("USERNAME") + if (!resourceName.canI(c, withAdmin = true)) throw forbidden() + val customerData = db.customerGetFromLogin(resourceName) ?: throw notFound( + "Customer '$resourceName' not found in the database.", + talerEc = TalerErrorCode.TALER_EC_END + ) + val bankAccountData = db.bankAccountGetFromOwnerId(customerData.expectRowId()) + ?: throw internalServerError("Customer '$resourceName' had no bank account despite they are customer.'") + val balance = Balance( + amount = bankAccountData.balance ?: throw internalServerError("Account '${customerData.login}' lacks balance!"), + credit_debit_indicator = if (bankAccountData.hasDebt) { + CorebankCreditDebitInfo.debit + } else { + CorebankCreditDebitInfo.credit + } + ) + call.respond( + AccountData( + name = customerData.name, + balance = balance, + debit_threshold = bankAccountData.maxDebt, + payto_uri = bankAccountData.internalPaytoUri, + contact_data = ChallengeContactData( + email = customerData.email, phone = customerData.phone + ), + cashout_payto_uri = customerData.cashoutPayto, + ) + ) + return@get + } + delete("/accounts/{USERNAME}") { + val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() + val resourceName = call.expectUriComponent("USERNAME") + // Checking rights. + if (c.login != "admin" && ctx.restrictAccountDeletion) + throw forbidden("Only admin allowed.") + if (!resourceName.canI(c, withAdmin = true)) + throw forbidden("Insufficient rights on this account.") + // Not deleting reserved names. + if (resourceName == "bank" || resourceName == "admin") + throw forbidden("Cannot delete reserved accounts.") + val res = db.customerDeleteIfBalanceIsZero(resourceName) + when (res) { + CustomerDeletionResult.CUSTOMER_NOT_FOUND -> + throw notFound( + "Customer '$resourceName' not found", + talerEc = TalerErrorCode.TALER_EC_NONE // FIXME: need EC. + ) + CustomerDeletionResult.BALANCE_NOT_ZERO -> + throw LibeufinBankException( + httpStatus = HttpStatusCode.PreconditionFailed, + talerError = TalerError( + hint = "Balance is not zero.", + code = TalerErrorCode.TALER_EC_NONE.code // FIXME: need EC. + ) + ) + CustomerDeletionResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) + } + return@delete + } + patch("/accounts/{USERNAME}/auth") { + val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() + val accountName = call.getResourceName("USERNAME") + if (!accountName.canI(c, withAdmin = true)) throw forbidden() + val req = call.receive<AccountPasswordChange>() + val hashedPassword = CryptoUtil.hashpw(req.new_password) + if (!db.customerChangePassword( + accountName, + hashedPassword + )) throw notFound( + "Account '$accountName' not found (despite it being authenticated by this call)", + talerEc = TalerErrorCode.TALER_EC_END // FIXME: need at least GENERIC_NOT_FOUND. + ) + call.respond(HttpStatusCode.NoContent) + return@patch + } + patch("/accounts/{USERNAME}") { + val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() + val accountName = call.getResourceName("USERNAME") + // preventing user non-admin X trying on resource Y. + if (!accountName.canI(c, withAdmin = true)) throw forbidden() + // admin is not allowed itself to change its own details. + if (accountName == "admin") throw forbidden("admin account not patchable") + // authentication OK, go on. + val req = call.receive<AccountReconfiguration>() + /** + * This object holds the details of the customer that's affected + * by this operation, as it MAY differ from the one being authenticated. + * This typically happens when admin did the request. + */ + val accountCustomer = db.customerGetFromLogin(accountName) ?: throw notFound( + "Account $accountName not found", + talerEc = TalerErrorCode.TALER_EC_END // FIXME, define EC. + ) + // Check if a non-admin user tried to change their legal name + if ((c.login != "admin") && (req.name != null) && (req.name != accountCustomer.name)) + throw forbidden("non-admin user cannot change their legal name") + // Preventing identical data to be overridden. + val bankAccount = db.bankAccountGetFromOwnerId(accountCustomer.expectRowId()) + ?: throw internalServerError("Customer '${accountCustomer.login}' lacks bank account.") + if ( + (req.is_exchange == bankAccount.isTalerExchange) && + (req.cashout_address == accountCustomer.cashoutPayto) && + (req.name == c.name) && + (req.challenge_contact_data?.phone == accountCustomer.phone) && + (req.challenge_contact_data?.email == accountCustomer.email) + ) { + call.respond(HttpStatusCode.NoContent) + return@patch + } + val dbRes = db.accountReconfig( + login = accountCustomer.login, + name = req.name, + cashoutPayto = req.cashout_address, + emailAddress = req.challenge_contact_data?.email, + isTalerExchange = req.is_exchange, + phoneNumber = req.challenge_contact_data?.phone + ) + when (dbRes) { + AccountReconfigDBResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) + AccountReconfigDBResult.CUSTOMER_NOT_FOUND -> { + // Rare case. Only possible if a deletion happened before the flow reaches here. + logger.warn("Authenticated customer wasn't found any more in the database") + throw notFound("Customer not found", TalerErrorCode.TALER_EC_END) // FIXME: needs EC + } + AccountReconfigDBResult.BANK_ACCOUNT_NOT_FOUND -> { + // Bank's fault: no customer should lack a bank account. + throw internalServerError("Customer '${accountCustomer.login}' lacks bank account") + } + } + return@patch + } + // WITHDRAWAL ENDPOINTS + post("/accounts/{USERNAME}/withdrawals") { + 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. + if(req.amount.currency != ctx.currency) + throw badRequest("Wrong currency: ${req.amount.currency}") + val b = db.bankAccountGetFromOwnerId(c.expectRowId()) + ?: throw internalServerError("Customer '${c.login}' lacks bank account.") + if (!isBalanceEnough( + balance = b.expectBalance(), due = req.amount, maxDebt = b.maxDebt, hasBalanceDebt = b.hasDebt + ) + ) throw forbidden( + hint = "Insufficient funds to withdraw with Taler", + talerErrorCode = TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT + ) // Auth and funds passed, create the operation now! + val opId = UUID.randomUUID() + if (!db.talerWithdrawalCreate( + opId, b.expectRowId(), req.amount + ) + ) throw internalServerError("Bank failed at creating the withdraw operation.") + + val bankBaseUrl = call.request.getBaseUrl() ?: throw internalServerError("Bank could not find its own base URL") + call.respond( + BankAccountCreateWithdrawalResponse( + withdrawal_id = opId.toString(), taler_withdraw_uri = getTalerWithdrawUri(bankBaseUrl, opId.toString()) + ) + ) + return@post + } + get("/withdrawals/{withdrawal_id}") { + val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) + call.respond( + BankAccountGetWithdrawalResponse( + amount = op.amount, + aborted = op.aborted, + confirmation_done = op.confirmationDone, + selection_done = op.selectionDone, + selected_exchange_account = op.selectedExchangePayto, + selected_reserve_pub = op.reservePub + ) + ) + return@get + } + post("/withdrawals/{withdrawal_id}/abort") { + 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). + if (!db.talerWithdrawalAbort(op.withdrawalUuid)) throw conflict( + hint = "Cannot abort confirmed withdrawal", talerEc = TalerErrorCode.TALER_EC_END + ) + call.respondText("{}", ContentType.Application.Json) + return@post + } + post("/withdrawals/{withdrawal_id}/confirm") { + 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. + 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. + when (db.talerWithdrawalConfirm(op.withdrawalUuid, Instant.now())) { + WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> + throw conflict( + "Insufficient funds", + TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT + ) + WithdrawalConfirmationResult.OP_NOT_FOUND -> + /** + * Despite previous checks, the database _still_ did not + * find the withdrawal operation, that's on the bank. + */ + throw internalServerError("Withdrawal operation (${op.withdrawalUuid}) not found") + + WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND -> + /** + * That can happen because the bank did not check the exchange + * exists when POST /withdrawals happened, or because the exchange + * bank account got removed before this confirmation. + */ + throw conflict( + hint = "Exchange to withdraw from not found", + talerEc = TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + + WithdrawalConfirmationResult.CONFLICT -> throw internalServerError("Bank didn't check for idempotency") + + WithdrawalConfirmationResult.SUCCESS -> call.respondText( + "{}", ContentType.Application.Json + ) + } + return@post + } + // TRANSACTION ENDPOINT + get("/accounts/{USERNAME}/transactions") { + val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() + val resourceName = call.getResourceName("USERNAME") + if (!resourceName.canI(c, withAdmin = true)) throw forbidden() + val historyParams = getHistoryParams(call.request.queryParameters) + val resourceCustomer = db.customerGetFromLogin(resourceName) ?: throw notFound( + hint = "Customer '$resourceName' not found in the database", + talerEc = TalerErrorCode.TALER_EC_END // FIXME: need EC. + ) + val bankAccount = db.bankAccountGetFromOwnerId(resourceCustomer.expectRowId()) + ?: throw internalServerError("Customer '${resourceCustomer.login}' lacks bank account.") + val history: List<BankAccountTransactionInfo> = db.bankPoolHistory(historyParams, bankAccount.expectRowId()) + call.respond(BankAccountTransactionsResponse(history)) + } + // Creates a bank transaction. + post("/accounts/{USERNAME}/transactions") { + val c: Customer = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() + val resourceName = call.expectUriComponent("USERNAME") // admin has no rights here. + if ((c.login != resourceName) && (call.getAuthToken() == null)) throw forbidden() + val tx = call.receive<BankAccountTransactionCreate>() + val subject = tx.payto_uri.message ?: throw badRequest("Wire transfer lacks subject") + val debtorBankAccount = db.bankAccountGetFromOwnerId(c.expectRowId()) + ?: throw internalServerError("Debtor bank account not found") + if (tx.amount.currency != ctx.currency) throw badRequest( + "Wrong currency: ${tx.amount.currency}", + talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH + ) + if (!isBalanceEnough( + balance = debtorBankAccount.expectBalance(), + due = tx.amount, + hasBalanceDebt = debtorBankAccount.hasDebt, + maxDebt = debtorBankAccount.maxDebt + )) + throw conflict(hint = "Insufficient balance.", talerEc = TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT) + logger.info("creditor payto: ${tx.payto_uri}") + val creditorBankAccount = db.bankAccountGetFromInternalPayto(tx.payto_uri) + ?: throw notFound( + "Creditor account not found", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + val dbInstructions = BankInternalTransaction( + debtorAccountId = debtorBankAccount.expectRowId(), + creditorAccountId = creditorBankAccount.expectRowId(), + subject = subject, + amount = tx.amount, + transactionDate = Instant.now() + ) + val res = db.bankTransactionCreate(dbInstructions) + when (res) { + BankTransactionResult.BALANCE_INSUFFICIENT -> throw conflict( + "Insufficient funds", + TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT + ) + BankTransactionResult.SAME_ACCOUNT -> throw conflict( + "Wire transfer attempted with credit and debit party being the same bank account", + TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT + ) + BankTransactionResult.NO_CREDITOR -> throw internalServerError("Creditor not found despite previous checks.") + BankTransactionResult.NO_DEBTOR -> throw internalServerError("Debtor not found despite the request was authenticated.") + BankTransactionResult.SUCCESS -> call.respond(HttpStatusCode.OK) + } + return@post + } + get("/accounts/{USERNAME}/transactions/{T_ID}") { + val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() + val accountName = call.getResourceName("USERNAME") + if (!accountName.canI(c, withAdmin = true)) throw forbidden() + val tId = call.expectUriComponent("T_ID") + val txRowId = try { + tId.toLong() + } catch (e: Exception) { + logger.error(e.message) + throw badRequest("TRANSACTION_ID is not a number: ${tId}") + } + val tx = db.bankTransactionGetFromInternalId(txRowId) ?: throw notFound( + "Bank transaction '$tId' not found", + TalerErrorCode.TALER_EC_BANK_TRANSACTION_NOT_FOUND + ) + val accountCustomer = db.customerGetFromLogin(accountName) ?: throw notFound( + hint = "Customer $accountName not found", + talerEc = TalerErrorCode.TALER_EC_END // FIXME: need EC. + ) + val customerBankAccount = db.bankAccountGetFromOwnerId(accountCustomer.expectRowId()) + ?: throw internalServerError("Customer '${accountCustomer.login}' lacks bank account.") + if (tx.bankAccountId != customerBankAccount.bankAccountId) throw forbidden("Client has no rights over the bank transaction: $tId") + call.respond( + BankAccountTransactionInfo( + amount = tx.amount, + creditor_payto_uri = tx.creditorPaytoUri, + debtor_payto_uri = tx.debtorPaytoUri, + date = TalerProtocolTimestamp(tx.transactionDate), + direction = tx.direction, + subject = tx.subject, + row_id = txRowId + ) + ) + return@get + } +} +\ 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 @@ -1,594 +0,0 @@ -package tech.libeufin.bank - -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import net.taler.common.errorcodes.TalerErrorCode -import net.taler.wallet.crypto.Base32Crockford -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import tech.libeufin.util.* -import java.time.Duration -import java.time.Instant -import java.time.temporal.ChronoUnit -import java.util.* - -private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.accountsMgmtHandlers") - -/** - * This function collects all the /accounts handlers that - * create, update, delete, show bank accounts. No histories - * and wire transfers should belong here. - */ -fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { - - // TOKEN ENDPOINTS - delete("/accounts/{USERNAME}/token") { - val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() - val token = call.getAuthToken() ?: throw badRequest("Basic auth not supported here.") - val resourceName = call.getResourceName("USERNAME") - /** - * The following check makes sure that the token belongs - * to the username contained in {USERNAME}. - */ - if (!resourceName.canI(c, withAdmin = true)) throw forbidden() - - /** - * Not sanity-checking the token, as it was used by the authentication already. - * If harder errors happen, then they'll get Ktor respond with 500. - */ - db.bearerTokenDelete(Base32Crockford.decode(token)) - /** - * Responding 204 regardless of it being actually deleted or not. - * If it wasn't found, then it must have been deleted before we - * reached here, but the token was valid as it served the authentication - * => no reason to fail the request. - */ - call.respond(HttpStatusCode.NoContent) - } - post("/accounts/{USERNAME}/token") { - 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_GENERIC_FORBIDDEN - ) - val maybeAuthToken = call.getAuthToken() - val req = call.receive<TokenRequest>() - /** - * This block checks permissions ONLY IF the call was authenticated - * with a token. Basic auth gets always granted. - */ - if (maybeAuthToken != null) { - val tokenBytes = Base32Crockford.decode(maybeAuthToken) - val refreshingToken = db.bearerTokenGet(tokenBytes) ?: throw internalServerError( - "Token used to auth not found in the database!" - ) - if (refreshingToken.scope == TokenScope.readonly && req.scope == TokenScope.readwrite) throw forbidden( - "Cannot generate RW token from RO", TalerErrorCode.TALER_EC_GENERIC_TOKEN_PERMISSION_INSUFFICIENT - ) - } - val tokenBytes = ByteArray(32).apply { - Random().nextBytes(this) - } - val tokenDuration: Duration = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION - - val creationTime = Instant.now() - val expirationTimestamp = if (tokenDuration == ChronoUnit.FOREVER.duration) { - logger.debug("Creating 'forever' token.") - Instant.MAX - } else { - try { - logger.debug("Creating token with days duration: ${tokenDuration.toDays()}") - creationTime.plus(tokenDuration) - } catch (e: Exception) { - logger.error("Could not add token duration to current time: ${e.message}") - throw badRequest("Bad token duration: ${e.message}") - } - } - val customerDbRow = customer.dbRowId ?: throw internalServerError( - "Could not get customer '${customer.login}' database row ID" - ) - val token = BearerToken( - bankCustomer = customerDbRow, - content = tokenBytes, - creationTime = creationTime, - expirationTime = expirationTimestamp, - scope = req.scope, - isRefreshable = req.refreshable - ) - if (!db.bearerTokenCreate(token)) - throw internalServerError("Failed at inserting new token in the database") - call.respond( - TokenSuccessResponse( - access_token = Base32Crockford.encode(tokenBytes), expiration = TalerProtocolTimestamp( - t_s = expirationTimestamp - ) - ) - ) - return@post - } - // ACCOUNT ENDPOINTS - get("/public-accounts") { - // no authentication here. - val publicAccounts = db.accountsGetPublic(ctx.currency) - if (publicAccounts.isEmpty()) { - call.respond(HttpStatusCode.NoContent) - return@get - } - call.respond( - PublicAccountsResponse().apply { - publicAccounts.forEach { - this.public_accounts.add(it) - } - } - ) - return@get - } - get("/accounts") { - val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() - if (c.login != "admin") throw forbidden("Only admin allowed.") - // Get optional param. - val maybeFilter: String? = call.request.queryParameters["filter_name"] - logger.debug("Filtering on '${maybeFilter}'") - val queryParam = if (maybeFilter != null) { - "%${maybeFilter}%" - } else "%" - val dbRes = db.accountsGetForAdmin(queryParam) - if (dbRes.isEmpty()) { - call.respond(HttpStatusCode.NoContent) - return@get - } - call.respond( - ListBankAccountsResponse().apply { - dbRes.forEach { element -> - this.accounts.add(element) - } - } - ) - return@get - } - 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( - httpStatus = HttpStatusCode.Unauthorized, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_GENERIC_UNAUTHORIZED.code, - 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: - if (req.username == "admin" || req.username == "bank") throw LibeufinBankException( - httpStatus = HttpStatusCode.Conflict, - talerError = TalerError( - code = TalerErrorCode.TALER_EC_BANK_RESERVED_USERNAME_CONFLICT.code, - hint = "Username '${req.username}' is reserved." - ) - ) // 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()) - } - val internalPayto = req.internal_payto_uri ?: IbanPayTo(genIbanPaytoUri()) - if (maybeCustomerExists != null && maybeHasBankAccount != null) { - 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) && - maybeHasBankAccount.isPublic == req.is_public && - maybeHasBankAccount.isTalerExchange == req.is_taler_exchange && - maybeHasBankAccount.internalPaytoUri.stripped == internalPayto.stripped - if (isIdentic) { - call.respond(HttpStatusCode.Created) - return@post - } - throw LibeufinBankException( - httpStatus = HttpStatusCode.Conflict, talerError = TalerError( - code = GENERIC_UNDEFINED, // FIXME: provide appropriate EC. - hint = "Idempotency check failed." - ) - ) - } - - // 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 - 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. - val maxDebt = ctx.defaultCustomerDebtLimit - val newBankAccount = BankAccount( - hasDebt = false, - internalPaytoUri = internalPayto, - owningCustomerId = newCustomerRowId, - isPublic = req.is_public, - isTalerExchange = req.is_taler_exchange, - maxDebt = maxDebt - ) - 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. - 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") - val adminBankAccount = db.bankAccountGetFromOwnerId(adminCustomer.expectRowId()) - ?: throw internalServerError("Admin bank account not found") - val adminPaysBonus = BankInternalTransaction( - creditorAccountId = newBankAccountId, - debtorAccountId = adminBankAccount.expectRowId(), - amount = bonusAmount, - subject = "Registration bonus.", - transactionDate = Instant.now() - ) - when (db.bankTransactionCreate(adminPaysBonus)) { - BankTransactionResult.NO_CREDITOR -> throw internalServerError("Bonus impossible: creditor not found, despite its recent creation.") - BankTransactionResult.NO_DEBTOR -> throw internalServerError("Bonus impossible: admin not found.") - BankTransactionResult.BALANCE_INSUFFICIENT -> throw internalServerError("Bonus impossible: admin has insufficient balance.") - BankTransactionResult.SAME_ACCOUNT -> throw internalServerError("Bonus impossible: admin should not be creditor.") - BankTransactionResult.SUCCESS -> {/* continue the execution */ - } - } - } - call.respond(HttpStatusCode.Created) - return@post - } - get("/accounts/{USERNAME}") { - val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized("Login failed") - val resourceName = call.expectUriComponent("USERNAME") - if (!resourceName.canI(c, withAdmin = true)) throw forbidden() - val customerData = db.customerGetFromLogin(resourceName) ?: throw notFound( - "Customer '$resourceName' not found in the database.", - talerEc = TalerErrorCode.TALER_EC_END - ) - val bankAccountData = db.bankAccountGetFromOwnerId(customerData.expectRowId()) - ?: throw internalServerError("Customer '$resourceName' had no bank account despite they are customer.'") - val balance = Balance( - amount = bankAccountData.balance ?: throw internalServerError("Account '${customerData.login}' lacks balance!"), - credit_debit_indicator = if (bankAccountData.hasDebt) { - CorebankCreditDebitInfo.debit - } else { - CorebankCreditDebitInfo.credit - } - ) - call.respond( - AccountData( - name = customerData.name, - balance = balance, - debit_threshold = bankAccountData.maxDebt, - payto_uri = bankAccountData.internalPaytoUri, - contact_data = ChallengeContactData( - email = customerData.email, phone = customerData.phone - ), - cashout_payto_uri = customerData.cashoutPayto, - ) - ) - return@get - } - delete("/accounts/{USERNAME}") { - val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() - val resourceName = call.expectUriComponent("USERNAME") - // Checking rights. - if (c.login != "admin" && ctx.restrictAccountDeletion) - throw forbidden("Only admin allowed.") - if (!resourceName.canI(c, withAdmin = true)) - throw forbidden("Insufficient rights on this account.") - // Not deleting reserved names. - if (resourceName == "bank" || resourceName == "admin") - throw forbidden("Cannot delete reserved accounts.") - val res = db.customerDeleteIfBalanceIsZero(resourceName) - when (res) { - CustomerDeletionResult.CUSTOMER_NOT_FOUND -> - throw notFound( - "Customer '$resourceName' not found", - talerEc = TalerErrorCode.TALER_EC_NONE // FIXME: need EC. - ) - CustomerDeletionResult.BALANCE_NOT_ZERO -> - throw LibeufinBankException( - httpStatus = HttpStatusCode.PreconditionFailed, - talerError = TalerError( - hint = "Balance is not zero.", - code = TalerErrorCode.TALER_EC_NONE.code // FIXME: need EC. - ) - ) - CustomerDeletionResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) - } - return@delete - } - patch("/accounts/{USERNAME}/auth") { - val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() - val accountName = call.getResourceName("USERNAME") - if (!accountName.canI(c, withAdmin = true)) throw forbidden() - val req = call.receive<AccountPasswordChange>() - val hashedPassword = CryptoUtil.hashpw(req.new_password) - if (!db.customerChangePassword( - accountName, - hashedPassword - )) throw notFound( - "Account '$accountName' not found (despite it being authenticated by this call)", - talerEc = TalerErrorCode.TALER_EC_END // FIXME: need at least GENERIC_NOT_FOUND. - ) - call.respond(HttpStatusCode.NoContent) - return@patch - } - patch("/accounts/{USERNAME}") { - val c = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() - val accountName = call.getResourceName("USERNAME") - // preventing user non-admin X trying on resource Y. - if (!accountName.canI(c, withAdmin = true)) throw forbidden() - // admin is not allowed itself to change its own details. - if (accountName == "admin") throw forbidden("admin account not patchable") - // authentication OK, go on. - val req = call.receive<AccountReconfiguration>() - /** - * This object holds the details of the customer that's affected - * by this operation, as it MAY differ from the one being authenticated. - * This typically happens when admin did the request. - */ - val accountCustomer = db.customerGetFromLogin(accountName) ?: throw notFound( - "Account $accountName not found", - talerEc = TalerErrorCode.TALER_EC_END // FIXME, define EC. - ) - // Check if a non-admin user tried to change their legal name - if ((c.login != "admin") && (req.name != null) && (req.name != accountCustomer.name)) - throw forbidden("non-admin user cannot change their legal name") - // Preventing identical data to be overridden. - val bankAccount = db.bankAccountGetFromOwnerId(accountCustomer.expectRowId()) - ?: throw internalServerError("Customer '${accountCustomer.login}' lacks bank account.") - if ( - (req.is_exchange == bankAccount.isTalerExchange) && - (req.cashout_address == accountCustomer.cashoutPayto) && - (req.name == c.name) && - (req.challenge_contact_data?.phone == accountCustomer.phone) && - (req.challenge_contact_data?.email == accountCustomer.email) - ) { - call.respond(HttpStatusCode.NoContent) - return@patch - } - val dbRes = db.accountReconfig( - login = accountCustomer.login, - name = req.name, - cashoutPayto = req.cashout_address, - emailAddress = req.challenge_contact_data?.email, - isTalerExchange = req.is_exchange, - phoneNumber = req.challenge_contact_data?.phone - ) - when (dbRes) { - AccountReconfigDBResult.SUCCESS -> call.respond(HttpStatusCode.NoContent) - AccountReconfigDBResult.CUSTOMER_NOT_FOUND -> { - // Rare case. Only possible if a deletion happened before the flow reaches here. - logger.warn("Authenticated customer wasn't found any more in the database") - throw notFound("Customer not found", TalerErrorCode.TALER_EC_END) // FIXME: needs EC - } - AccountReconfigDBResult.BANK_ACCOUNT_NOT_FOUND -> { - // Bank's fault: no customer should lack a bank account. - throw internalServerError("Customer '${accountCustomer.login}' lacks bank account") - } - } - return@patch - } - // WITHDRAWAL ENDPOINTS - post("/accounts/{USERNAME}/withdrawals") { - 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. - if(req.amount.currency != ctx.currency) - throw badRequest("Wrong currency: ${req.amount.currency}") - val b = db.bankAccountGetFromOwnerId(c.expectRowId()) - ?: throw internalServerError("Customer '${c.login}' lacks bank account.") - if (!isBalanceEnough( - balance = b.expectBalance(), due = req.amount, maxDebt = b.maxDebt, hasBalanceDebt = b.hasDebt - ) - ) throw forbidden( - hint = "Insufficient funds to withdraw with Taler", - talerErrorCode = TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT - ) // Auth and funds passed, create the operation now! - val opId = UUID.randomUUID() - if (!db.talerWithdrawalCreate( - opId, b.expectRowId(), req.amount - ) - ) throw internalServerError("Bank failed at creating the withdraw operation.") - - val bankBaseUrl = call.request.getBaseUrl() ?: throw internalServerError("Bank could not find its own base URL") - call.respond( - BankAccountCreateWithdrawalResponse( - withdrawal_id = opId.toString(), taler_withdraw_uri = getTalerWithdrawUri(bankBaseUrl, opId.toString()) - ) - ) - return@post - } - get("/withdrawals/{withdrawal_id}") { - val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) - call.respond( - BankAccountGetWithdrawalResponse( - amount = op.amount, - aborted = op.aborted, - confirmation_done = op.confirmationDone, - selection_done = op.selectionDone, - selected_exchange_account = op.selectedExchangePayto, - selected_reserve_pub = op.reservePub - ) - ) - return@get - } - post("/withdrawals/{withdrawal_id}/abort") { - 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). - if (!db.talerWithdrawalAbort(op.withdrawalUuid)) throw conflict( - hint = "Cannot abort confirmed withdrawal", talerEc = TalerErrorCode.TALER_EC_END - ) - call.respondText("{}", ContentType.Application.Json) - return@post - } - post("/withdrawals/{withdrawal_id}/confirm") { - 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. - 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. - when (db.talerWithdrawalConfirm(op.withdrawalUuid, Instant.now())) { - WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> - throw conflict( - "Insufficient funds", - TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT - ) - WithdrawalConfirmationResult.OP_NOT_FOUND -> - /** - * Despite previous checks, the database _still_ did not - * find the withdrawal operation, that's on the bank. - */ - throw internalServerError("Withdrawal operation (${op.withdrawalUuid}) not found") - - WithdrawalConfirmationResult.EXCHANGE_NOT_FOUND -> - /** - * That can happen because the bank did not check the exchange - * exists when POST /withdrawals happened, or because the exchange - * bank account got removed before this confirmation. - */ - throw conflict( - hint = "Exchange to withdraw from not found", - talerEc = TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) - - WithdrawalConfirmationResult.CONFLICT -> throw internalServerError("Bank didn't check for idempotency") - - WithdrawalConfirmationResult.SUCCESS -> call.respondText( - "{}", ContentType.Application.Json - ) - } - return@post - } - // TRANSACTION ENDPOINT - get("/accounts/{USERNAME}/transactions") { - val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() - val resourceName = call.getResourceName("USERNAME") - if (!resourceName.canI(c, withAdmin = true)) throw forbidden() - val historyParams = getHistoryParams(call.request.queryParameters) - val resourceCustomer = db.customerGetFromLogin(resourceName) ?: throw notFound( - hint = "Customer '$resourceName' not found in the database", - talerEc = TalerErrorCode.TALER_EC_END // FIXME: need EC. - ) - val bankAccount = db.bankAccountGetFromOwnerId(resourceCustomer.expectRowId()) - ?: throw internalServerError("Customer '${resourceCustomer.login}' lacks bank account.") - val history: List<BankAccountTransactionInfo> = db.bankPoolHistory(historyParams, bankAccount.expectRowId()) - call.respond(BankAccountTransactionsResponse(history)) - } - // Creates a bank transaction. - post("/accounts/{USERNAME}/transactions") { - val c: Customer = call.authenticateBankRequest(db, TokenScope.readwrite) ?: throw unauthorized() - val resourceName = call.expectUriComponent("USERNAME") // admin has no rights here. - if ((c.login != resourceName) && (call.getAuthToken() == null)) throw forbidden() - val tx = call.receive<BankAccountTransactionCreate>() - val subject = tx.payto_uri.message ?: throw badRequest("Wire transfer lacks subject") - val debtorBankAccount = db.bankAccountGetFromOwnerId(c.expectRowId()) - ?: throw internalServerError("Debtor bank account not found") - if (tx.amount.currency != ctx.currency) throw badRequest( - "Wrong currency: ${tx.amount.currency}", - talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH - ) - if (!isBalanceEnough( - balance = debtorBankAccount.expectBalance(), - due = tx.amount, - hasBalanceDebt = debtorBankAccount.hasDebt, - maxDebt = debtorBankAccount.maxDebt - )) - throw conflict(hint = "Insufficient balance.", talerEc = TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT) - logger.info("creditor payto: ${tx.payto_uri}") - val creditorBankAccount = db.bankAccountGetFromInternalPayto(tx.payto_uri) - ?: throw notFound( - "Creditor account not found", - TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) - val dbInstructions = BankInternalTransaction( - debtorAccountId = debtorBankAccount.expectRowId(), - creditorAccountId = creditorBankAccount.expectRowId(), - subject = subject, - amount = tx.amount, - transactionDate = Instant.now() - ) - val res = db.bankTransactionCreate(dbInstructions) - when (res) { - BankTransactionResult.BALANCE_INSUFFICIENT -> throw conflict( - "Insufficient funds", - TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT - ) - BankTransactionResult.SAME_ACCOUNT -> throw conflict( - "Wire transfer attempted with credit and debit party being the same bank account", - TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT - ) - BankTransactionResult.NO_CREDITOR -> throw internalServerError("Creditor not found despite previous checks.") - BankTransactionResult.NO_DEBTOR -> throw internalServerError("Debtor not found despite the request was authenticated.") - BankTransactionResult.SUCCESS -> call.respond(HttpStatusCode.OK) - } - return@post - } - get("/accounts/{USERNAME}/transactions/{T_ID}") { - val c = call.authenticateBankRequest(db, TokenScope.readonly) ?: throw unauthorized() - val accountName = call.getResourceName("USERNAME") - if (!accountName.canI(c, withAdmin = true)) throw forbidden() - val tId = call.expectUriComponent("T_ID") - val txRowId = try { - tId.toLong() - } catch (e: Exception) { - logger.error(e.message) - throw badRequest("TRANSACTION_ID is not a number: ${tId}") - } - val tx = db.bankTransactionGetFromInternalId(txRowId) ?: throw notFound( - "Bank transaction '$tId' not found", - TalerErrorCode.TALER_EC_BANK_TRANSACTION_NOT_FOUND - ) - val accountCustomer = db.customerGetFromLogin(accountName) ?: throw notFound( - hint = "Customer $accountName not found", - talerEc = TalerErrorCode.TALER_EC_END // FIXME: need EC. - ) - val customerBankAccount = db.bankAccountGetFromOwnerId(accountCustomer.expectRowId()) - ?: throw internalServerError("Customer '${accountCustomer.login}' lacks bank account.") - if (tx.bankAccountId != customerBankAccount.bankAccountId) throw forbidden("Client has no rights over the bank transaction: $tId") - call.respond( - BankAccountTransactionInfo( - amount = tx.amount, - creditor_payto_uri = tx.creditorPaytoUri, - debtor_payto_uri = tx.debtorPaytoUri, - date = TalerProtocolTimestamp(tx.transactionDate), - direction = tx.direction, - subject = tx.subject, - row_id = txRowId - ) - ) - return@get - } -} -\ No newline at end of file diff --git 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/IntegrationApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/IntegrationApiHandlers.kt @@ -1,101 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. - - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -/* This file contains the Taler Integration API endpoints, -* that are typically requested by wallets. */ -package tech.libeufin.bank - -import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import net.taler.common.errorcodes.TalerErrorCode - -fun Routing.talerIntegrationHandlers(db: Database, ctx: BankApplicationContext) { - get("/taler-integration/config") { - val internalCurrency: String = ctx.currency - call.respond(TalerIntegrationConfigResponse( - currency = internalCurrency, - currency_specification = ctx.currencySpecification - )) - return@get - } - - // 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) - ?: throw internalServerError("Bank has a withdrawal not related to any bank account.") - val suggestedExchange = ctx.suggestedWithdrawalExchange - val confirmUrl = if (ctx.spaCaptchaURL == null) null else - getWithdrawalConfirmUrl( - baseUrl = ctx.spaCaptchaURL, - wopId = wopid - ) - call.respond( - BankWithdrawalOperationStatus( - aborted = op.aborted, - selection_done = op.selectionDone, - transfer_done = op.confirmationDone, - amount = op.amount, - sender_wire = relatedBankAccount.internalPaytoUri.stripped, - suggested_exchange = suggestedExchange, - confirm_transfer_url = confirmUrl - ) - ) - return@get - } - post("/taler-integration/withdrawal-operation/{wopid}") { - 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 - ) - } - 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 - ) - db.talerWithdrawalSetDetails( - op.withdrawalUuid, req.selected_exchange, req.reserve_pub - ) - } 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.") - // Getting user details that MIGHT be used later. - val confirmUrl: String? = if (ctx.spaCaptchaURL !== null && !op.confirmationDone) { - getWithdrawalConfirmUrl( - baseUrl = ctx.spaCaptchaURL, - wopId = wopid - ) - } else null - val resp = BankWithdrawalOperationPostResponse( - transfer_done = op.confirmationDone, confirm_transfer_url = confirmUrl - ) - call.respond(resp) - return@post - } -} -\ No newline at end of file diff --git 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 @@ -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/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApi.kt @@ -0,0 +1,196 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2019 Stanisci and Dold. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +// This file contains the Taler Wire Gateway API handlers. + +package tech.libeufin.bank + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import net.taler.common.errorcodes.TalerErrorCode +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import tech.libeufin.util.extractReservePubFromSubject +import tech.libeufin.util.stripIbanPayto +import java.time.Instant +import kotlin.math.abs + +private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus") + +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") + val username = getResourceName("USERNAME") + if (!username.canI(authCustomer, withAdmin)) throw unauthorized("No right on $username account") + return username + } + + /** Retrieve the bank account info for the selected username*/ + suspend fun ApplicationCall.bankAccount(): Database.BankInfo { + val username = getResourceName("USERNAME") + return db.bankAccountInfoFromCustomerLogin(username) ?: throw notFound( + hint = "Customer $username not found", + talerEc = TalerErrorCode.TALER_EC_END // FIXME: need EC. + ) + } + + get("/taler-wire-gateway/config") { + call.respond(TWGConfigResponse(currency = ctx.currency)) + return@get + } + + post("/accounts/{USERNAME}/taler-wire-gateway/transfer") { + val username = call.authCheck(TokenScope.readwrite, true) + val req = call.receive<TransferRequest>() + if (req.amount.currency != ctx.currency) + throw badRequest( + "Currency mismatch", + TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH + ) + val dbRes = db.talerTransferCreate( + req = req, + username = username, + timestamp = Instant.now() + ) + when (dbRes.txResult) { + TalerTransferResult.NO_DEBITOR -> throw notFound( + "Customer $username not found", + TalerErrorCode.TALER_EC_END // FIXME: need EC. + ) + TalerTransferResult.NOT_EXCHANGE -> throw conflict( + "$username is not an exchange account.", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + TalerTransferResult.NO_CREDITOR -> throw notFound( + "Creditor account was not found", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + TalerTransferResult.SAME_ACCOUNT -> throw conflict( + "Wire transfer attempted with credit and debit party being the same bank account", + TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT + ) + TalerTransferResult.BOTH_EXCHANGE -> throw conflict( + "Wire transfer attempted with credit and debit party being both exchange account", + TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT + ) + TalerTransferResult.REQUEST_UID_REUSE -> throw conflict( + "request_uid used already", + TalerErrorCode.TALER_EC_BANK_TRANSFER_REQUEST_UID_REUSED + ) + TalerTransferResult.BALANCE_INSUFFICIENT -> throw conflict( + "Insufficient balance for exchange", + TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT + ) + TalerTransferResult.SUCCESS -> call.respond( + TransferResponse( + timestamp = dbRes.timestamp!!, + row_id = dbRes.txRowId!! + ) + ) + } + } + + suspend fun <T> historyEndpoint( + call: ApplicationCall, + reduce: (List<T>, String) -> Any, + dbLambda: suspend Database.(HistoryParams, Long) -> List<T> + ) { + val username = call.authCheck(TokenScope.readonly, true) + val params = getHistoryParams(call.request.queryParameters) + val bankAccount = call.bankAccount() + + if (!bankAccount.isTalerExchange) + throw conflict( + "$username is not an exchange account.", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + + val items = db.dbLambda(params, bankAccount.id); + + if (items.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(reduce(items, bankAccount.internalPaytoUri)) + } + } + + get("/accounts/{USERNAME}/taler-wire-gateway/history/incoming") { + historyEndpoint(call, ::IncomingHistory, Database::exchangeIncomingPoolHistory) + } + + get("/accounts/{USERNAME}/taler-wire-gateway/history/outgoing") { + historyEndpoint(call, ::OutgoingHistory, Database::exchangeOutgoingPoolHistory) + } + + post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") { + val username = call.authCheck(TokenScope.readwrite, false) + val req = call.receive<AddIncomingRequest>() + if (req.amount.currency != ctx.currency) + throw badRequest( + "Currency mismatch", + TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH + ) + val timestamp = Instant.now() + val dbRes = db.talerAddIncomingCreate( + req = req, + username = username, + timestamp = timestamp + ) + when (dbRes.txResult) { + TalerAddIncomingResult.NO_CREDITOR -> throw notFound( + "Customer $username not found", + TalerErrorCode.TALER_EC_END // FIXME: need EC. + ) + TalerAddIncomingResult.NOT_EXCHANGE -> throw conflict( + "$username is not an exchange account.", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + TalerAddIncomingResult.NO_DEBITOR -> throw notFound( + "Debitor account was not found", + TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT + ) + TalerAddIncomingResult.SAME_ACCOUNT -> throw conflict( + "Wire transfer attempted with credit and debit party being the same bank account", + TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT + ) + TalerAddIncomingResult.BOTH_EXCHANGE -> throw conflict( + "Wire transfer attempted with credit and debit party being both exchange account", + TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT + ) + TalerAddIncomingResult.RESERVE_PUB_REUSE -> throw conflict( + "reserve_pub used already", + TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT + ) + TalerAddIncomingResult.BALANCE_INSUFFICIENT -> throw conflict( + "Insufficient balance for debitor", + TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT + ) + TalerAddIncomingResult.SUCCESS -> call.respond( + AddIncomingResponse( + timestamp = TalerProtocolTimestamp(timestamp), + row_id = dbRes.txRowId!! + ) + ) + } + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt @@ -1,196 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. - - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -// This file contains the Taler Wire Gateway API handlers. - -package tech.libeufin.bank - -import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.server.response.* -import io.ktor.server.routing.* -import net.taler.common.errorcodes.TalerErrorCode -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import tech.libeufin.util.extractReservePubFromSubject -import tech.libeufin.util.stripIbanPayto -import java.time.Instant -import kotlin.math.abs - -private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus") - -fun Routing.talerWireGatewayHandlers(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") - val username = getResourceName("USERNAME") - if (!username.canI(authCustomer, withAdmin)) throw unauthorized("No right on $username account") - return username - } - - /** Retrieve the bank account info for the selected username*/ - suspend fun ApplicationCall.bankAccount(): Database.BankInfo { - val username = getResourceName("USERNAME") - return db.bankAccountInfoFromCustomerLogin(username) ?: throw notFound( - hint = "Customer $username not found", - talerEc = TalerErrorCode.TALER_EC_END // FIXME: need EC. - ) - } - - get("/taler-wire-gateway/config") { - call.respond(TWGConfigResponse(currency = ctx.currency)) - return@get - } - - post("/accounts/{USERNAME}/taler-wire-gateway/transfer") { - val username = call.authCheck(TokenScope.readwrite, true) - val req = call.receive<TransferRequest>() - if (req.amount.currency != ctx.currency) - throw badRequest( - "Currency mismatch", - TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH - ) - val dbRes = db.talerTransferCreate( - req = req, - username = username, - timestamp = Instant.now() - ) - when (dbRes.txResult) { - TalerTransferResult.NO_DEBITOR -> throw notFound( - "Customer $username not found", - TalerErrorCode.TALER_EC_END // FIXME: need EC. - ) - TalerTransferResult.NOT_EXCHANGE -> throw conflict( - "$username is not an exchange account.", - TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) - TalerTransferResult.NO_CREDITOR -> throw notFound( - "Creditor account was not found", - TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) - TalerTransferResult.SAME_ACCOUNT -> throw conflict( - "Wire transfer attempted with credit and debit party being the same bank account", - TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT - ) - TalerTransferResult.BOTH_EXCHANGE -> throw conflict( - "Wire transfer attempted with credit and debit party being both exchange account", - TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT - ) - TalerTransferResult.REQUEST_UID_REUSE -> throw conflict( - "request_uid used already", - TalerErrorCode.TALER_EC_BANK_TRANSFER_REQUEST_UID_REUSED - ) - TalerTransferResult.BALANCE_INSUFFICIENT -> throw conflict( - "Insufficient balance for exchange", - TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT - ) - TalerTransferResult.SUCCESS -> call.respond( - TransferResponse( - timestamp = dbRes.timestamp!!, - row_id = dbRes.txRowId!! - ) - ) - } - } - - suspend fun <T> historyEndpoint( - call: ApplicationCall, - reduce: (List<T>, String) -> Any, - dbLambda: suspend Database.(HistoryParams, Long) -> List<T> - ) { - val username = call.authCheck(TokenScope.readonly, true) - val params = getHistoryParams(call.request.queryParameters) - val bankAccount = call.bankAccount() - - if (!bankAccount.isTalerExchange) - throw conflict( - "$username is not an exchange account.", - TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) - - val items = db.dbLambda(params, bankAccount.id); - - if (items.isEmpty()) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(reduce(items, bankAccount.internalPaytoUri)) - } - } - - get("/accounts/{USERNAME}/taler-wire-gateway/history/incoming") { - historyEndpoint(call, ::IncomingHistory, Database::exchangeIncomingPoolHistory) - } - - get("/accounts/{USERNAME}/taler-wire-gateway/history/outgoing") { - historyEndpoint(call, ::OutgoingHistory, Database::exchangeOutgoingPoolHistory) - } - - post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") { - val username = call.authCheck(TokenScope.readwrite, false) - val req = call.receive<AddIncomingRequest>() - if (req.amount.currency != ctx.currency) - throw badRequest( - "Currency mismatch", - TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH - ) - val timestamp = Instant.now() - val dbRes = db.talerAddIncomingCreate( - req = req, - username = username, - timestamp = timestamp - ) - when (dbRes.txResult) { - TalerAddIncomingResult.NO_CREDITOR -> throw notFound( - "Customer $username not found", - TalerErrorCode.TALER_EC_END // FIXME: need EC. - ) - TalerAddIncomingResult.NOT_EXCHANGE -> throw conflict( - "$username is not an exchange account.", - TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) - TalerAddIncomingResult.NO_DEBITOR -> throw notFound( - "Debitor account was not found", - TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT - ) - TalerAddIncomingResult.SAME_ACCOUNT -> throw conflict( - "Wire transfer attempted with credit and debit party being the same bank account", - TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT - ) - TalerAddIncomingResult.BOTH_EXCHANGE -> throw conflict( - "Wire transfer attempted with credit and debit party being both exchange account", - TalerErrorCode.TALER_EC_BANK_SAME_ACCOUNT - ) - TalerAddIncomingResult.RESERVE_PUB_REUSE -> throw conflict( - "reserve_pub used already", - TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT - ) - TalerAddIncomingResult.BALANCE_INSUFFICIENT -> throw conflict( - "Insufficient balance for debitor", - TalerErrorCode.TALER_EC_BANK_UNALLOWED_DEBIT - ) - TalerAddIncomingResult.SUCCESS -> call.respond( - AddIncomingResponse( - timestamp = TalerProtocolTimestamp(timestamp), - row_id = dbRes.txRowId!! - ) - ) - } - } -} -\ No newline at end of file diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt 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 @@ -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 @@ -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 @@ -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 @@ -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(