libeufin

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

commit a03159906df6342432c238a6f7956a4872498443
parent 9e836fd04bf792b2f6b87c17a9ab71c194a9d523
Author: MS <ms@taler.net>
Date:   Tue, 19 Sep 2023 18:30:22 +0200

Taler withdrawal: create and abort.

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 23++++++++++++++++++++++-
Mbank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mbank/src/main/kotlin/tech/libeufin/bank/types.kt | 24++++++++++++++++++++++++
Abank/src/test/kotlin/TalerApiTest.kt | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dbank/src/test/kotlin/TalerTest.kt | 35-----------------------------------
5 files changed, 229 insertions(+), 54 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -144,7 +144,7 @@ class Database(private val dbConfig: String) { } res.use { if (!it.next()) - throw internalServerError("SQL RETURNING gave nothing.") + throw internalServerError("SQL RETURNING gave no customer_id.") return it.getLong("customer_id") } } @@ -615,6 +615,27 @@ class Database(private val dbConfig: String) { } } + /** + * Aborts one Taler withdrawal, only if it wasn't previously + * confirmed. It returns false if the UPDATE didn't succeed. + */ + fun talerWithdrawalAbort(opUUID: UUID): Boolean { + reconnect() + val stmt = prepare(""" + UPDATE taler_withdrawal_operations + SET aborted = true + WHERE withdrawal_uuid=? AND selection_done = false + RETURNING taler_withdrawal_id + """ + ) + stmt.setObject(1, opUUID) + val res = stmt.executeQuery() + res.use { + if (!it.next()) return false + } + return true + } + // Values coming from the wallet. fun talerWithdrawalSetDetails( opUUID: UUID, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt @@ -24,6 +24,7 @@ package tech.libeufin.bank +import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.plugins.* import io.ktor.server.request.* @@ -34,6 +35,27 @@ import net.taler.wallet.crypto.Base32Crockford import tech.libeufin.util.getBaseUrl import java.util.* +/** + * This handler factors out the checking of the query param + * and the retrieval of the related withdrawal database row. + * It throws 404 if the operation is not found, and throws 400 + * if the query param doesn't parse into an UUID. + */ +private fun getWithdrawal(opIdParam: String): TalerWithdrawalOperation { + val opId = try { + UUID.fromString(opIdParam) + } catch (e: Exception) { + logger.error(e.message) + throw badRequest("withdrawal_id query parameter was malformed") + } + val op = db.talerWithdrawalGet(opId) + ?: throw notFound( + hint = "Withdrawal operation ${opIdParam} not found", + talerEc = TalerErrorCode.TALER_EC_END + ) + return op +} + fun Routing.talerWebHandlers() { post("/accounts/{USERNAME}/withdrawals") { val c = call.myAuth(TokenScope.readwrite) ?: throw unauthorized() @@ -76,25 +98,13 @@ fun Routing.talerWebHandlers() { )) return@post } - get("/accounts/{USERNAME}/withdrawals/{W_ID}") { + get("/accounts/{USERNAME}/withdrawals/{withdrawal_id}") { val c = call.myAuth(TokenScope.readonly) ?: throw unauthorized() val accountName = call.expectUriComponent("USERNAME") // Admin allowed to see the details if (c.login != accountName && c.login != "admin") throw forbidden() // Permissions passed, get the information. - val opIdParam: String = call.request.queryParameters.get("W_ID") ?: throw - MissingRequestParameterException("withdrawal_id") - val opId = try { - UUID.fromString(opIdParam) - } catch (e: Exception) { - logger.error(e.message) - throw badRequest("withdrawal_id query parameter was malformed") - } - val op = db.talerWithdrawalGet(opId) - ?: throw notFound( - hint = "Withdrawal operation ${opIdParam} not found", - talerEc = TalerErrorCode.TALER_EC_END - ) + val op = getWithdrawal(call.expectUriComponent("withdrawal_id")) call.respond(BankAccountGetWithdrawalResponse( amount = op.amount.toString(), aborted = op.aborted, @@ -107,11 +117,51 @@ fun Routing.talerWebHandlers() { )) return@get } - post("/accounts/{USERNAME}/withdrawals/abort") { - throw NotImplementedError() + post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort") { + val c = call.myAuth(TokenScope.readonly) ?: throw unauthorized() + // Admin allowed to abort. + if (!call.getResourceName("USERNAME").canI(c)) throw forbidden() + val op = getWithdrawal(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("/accounts/{USERNAME}/withdrawals/confirm") { - throw NotImplementedError() + post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") { + val c = call.myAuth(TokenScope.readwrite) ?: throw unauthorized() + // No admin allowed. + if(!call.getResourceName("USERNAME").canI(c, withAdmin = false)) throw forbidden() + val op = getWithdrawal(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. + */ + throw NotImplementedError("Need a database transaction now?") } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/types.kt b/bank/src/main/kotlin/tech/libeufin/bank/types.kt @@ -20,6 +20,7 @@ package tech.libeufin.bank import io.ktor.http.* +import io.ktor.server.application.* import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import java.util.* @@ -406,3 +407,25 @@ data class BankAccountGetWithdrawalResponse( val selected_reserve_pub: String? = null, val selected_exchange_account: String? = null ) + +typealias ResourceName = String + + +// Checks if the input Customer has the rights over ResourceName +fun ResourceName.canI(c: Customer, withAdmin: Boolean = true): Boolean { + if (c.login == this) return true + if (c.login == "admin" && withAdmin) return true + return false +} + +/** + * Factors out the retrieval of the resource name from + * the URI. The resource looked for defaults to "USERNAME" + * as this is frequently mentioned resource along the endpoints. + * + * This helper is recommended because it returns a ResourceName + * type that then offers the ".canI()" helper to check if the user + * has the rights on the resource. + */ +fun ApplicationCall.getResourceName(param: String): ResourceName = + this.expectUriComponent(param) +\ No newline at end of file diff --git a/bank/src/test/kotlin/TalerApiTest.kt b/bank/src/test/kotlin/TalerApiTest.kt @@ -0,0 +1,114 @@ +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.* +import kotlinx.serialization.json.Json +import org.junit.Ignore +import org.junit.Test +import tech.libeufin.bank.* +import tech.libeufin.util.CryptoUtil +import java.util.* + +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 = "FOO-IBAN-XYZ", + lastNexusFetchRowId = 1L, + owningCustomerId = 1L, + hasDebt = false, + maxDebt = TalerAmount(10, 1, "KUDOS") + ) + // Testing withdrawal abort + @Test + fun withdrawalAbort() { + val db = initDb() + val uuid = UUID.randomUUID() + assert(db.customerCreate(customerFoo) != null) + assert(db.bankAccountCreate(bankAccountFoo)) + // insert new. + assert(db.talerWithdrawalCreate( + opUUID = uuid, + walletBankAccount = 1L, + amount = TalerAmount(1, 0) + )) + val op = db.talerWithdrawalGet(uuid) + assert(op?.aborted == false) + testApplication { + application(webApp) + client.post("/accounts/foo/withdrawals/${uuid}/abort") { + expectSuccess = true + basicAuth("foo", "pw") + } + } + val opAbo = db.talerWithdrawalGet(uuid) + assert(opAbo?.aborted == true) + } + // Testing withdrawal creation + @Test + fun withdrawalCreation() { + val db = initDb() + assert(db.customerCreate(customerFoo) != null) + assert(db.bankAccountCreate(bankAccountFoo)) + testApplication { + application(webApp) + // Creating the withdrawal as if the SPA did it. + val r = client.post("/accounts/foo/withdrawals") { + basicAuth("foo", "pw") + contentType(ContentType.Application.Json) + expectSuccess = true + setBody(""" + {"amount": "KUDOS:9"} + """.trimIndent()) + } + val opId = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(r.bodyAsText()) + // Getting the withdrawal from the bank. Throws (failing the test) if not found. + client.get("/accounts/foo/withdrawals/${opId.withdrawal_id}") { + expectSuccess = true + basicAuth("foo", "pw") + } + } + } + // Testing withdrawal confirmation + @Ignore + fun withdrawalConfirmation() { + assert(false) + } + // Testing the generation of taler://withdraw-URIs. + @Test + fun testWithdrawUri() { + // Checking the taler+http://-style. + val withHttp = getTalerWithdrawUri( + "http://example.com", + "my-id" + ) + assert(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 + assert(onlyTaler == "taler://withdraw/example.com/taler-integration/my-id") + // Checking the removal of subsequent slashes + val manySlashes = getTalerWithdrawUri( + "https://www.example.com//////", + "my-id" + ) + assert(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" + ) + assert(withPort == "taler://withdraw/www.example.com:9876/taler-integration/my-id") + } +} +\ No newline at end of file diff --git a/bank/src/test/kotlin/TalerTest.kt b/bank/src/test/kotlin/TalerTest.kt @@ -1,34 +0,0 @@ -import org.junit.Test -import tech.libeufin.bank.getTalerWithdrawUri - -class TalerTest { - // Testing the generation of taler://withdraw-URIs. - @Test - fun testWithdrawUri() { - // Checking the taler+http://-style. - val withHttp = getTalerWithdrawUri( - "http://example.com", - "my-id" - ) - assert(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 - assert(onlyTaler == "taler://withdraw/example.com/taler-integration/my-id") - // Checking the removal of subsequent slashes - val manySlashes = getTalerWithdrawUri( - "https://www.example.com//////", - "my-id" - ) - assert(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" - ) - assert(withPort == "taler://withdraw/www.example.com:9876/taler-integration/my-id") - } -} -\ No newline at end of file