commit a03159906df6342432c238a6f7956a4872498443
parent 9e836fd04bf792b2f6b87c17a9ab71c194a9d523
Author: MS <ms@taler.net>
Date: Tue, 19 Sep 2023 18:30:22 +0200
Taler withdrawal: create and abort.
Diffstat:
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