summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAntoine A <>2023-10-18 16:21:18 +0000
committerAntoine A <>2023-10-18 16:21:18 +0000
commita55388ced30d0acb9e1b21f2c68d6403afebb21a (patch)
tree555c8d96bed8a66d28e714e942d872c66bcdb682
parent94cb68f30e1b0a6d5fdb61d1917c8f08337b1f7a (diff)
downloadlibeufin-a55388ced30d0acb9e1b21f2c68d6403afebb21a.tar.gz
libeufin-a55388ced30d0acb9e1b21f2c68d6403afebb21a.tar.bz2
libeufin-a55388ced30d0acb9e1b21f2c68d6403afebb21a.zip
Improve Taler Bank Integration APIv0.9.3-dev.27
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt68
-rw-r--r--bank/src/main/kotlin/tech/libeufin/bank/Database.kt41
-rw-r--r--bank/src/test/kotlin/BankIntegrationApiTest.kt137
-rw-r--r--bank/src/test/kotlin/DatabaseTest.kt4
-rw-r--r--bank/src/test/kotlin/helpers.kt12
-rw-r--r--database-versioning/procedures.sql62
6 files changed, 252 insertions, 72 deletions
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt
index 5246af3a..aebb8782 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/BankIntegrationApi.kt
@@ -26,6 +26,7 @@ import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import net.taler.common.errorcodes.TalerErrorCode
+import java.util.*
fun Routing.bankIntegrationApi(db: Database, ctx: BankApplicationContext) {
get("/taler-integration/config") {
@@ -38,6 +39,7 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankApplicationContext) {
// Note: wopid acts as an authentication token.
get("/taler-integration/withdrawal-operation/{wopid}") {
val wopid = call.expectUriComponent("wopid")
+ // TODO long poll
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.")
@@ -61,40 +63,48 @@ fun Routing.bankIntegrationApi(db: Database, ctx: BankApplicationContext) {
}
post("/taler-integration/withdrawal-operation/{wopid}") {
val wopid = call.expectUriComponent("wopid")
+ val uuid = try {
+ UUID.fromString(wopid)
+ } catch (e: Exception) {
+ throw badRequest("withdrawal_id query parameter was malformed")
+ }
val req = call.receive<BankWithdrawalOperationPostRequest>()
- val op = getWithdrawal(db, wopid) // throws 404 if not found.
- // TODO move logic into DB
- 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 (result, confirmationDone) = db.talerWithdrawalSetDetails(
+ uuid, req.selected_exchange, req.reserve_pub
+ )
+ when (result) {
+ WithdrawalSelectionResult.OP_NOT_FOUND -> throw notFound(
+ "Withdrawal operation $uuid not found",
+ TalerErrorCode.TALER_EC_END
)
- }
- // TODO check account is exchange
- val dbSuccess: Boolean = if (!op.selectionDone) { // Check if reserve pub. was used in _another_ withdrawal.
- if (db.checkReservePubReuse(req.reserve_pub)) throw conflict(
- "Reserve pub. already used", TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT
+ WithdrawalSelectionResult.ALREADY_SELECTED -> throw conflict(
+ "Cannot select different exchange and reserve pub. under the same withdrawal operation",
+ TalerErrorCode.TALER_EC_BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT
)
- db.talerWithdrawalSetDetails(
- op.withdrawalUuid, req.selected_exchange, req.reserve_pub
+ WithdrawalSelectionResult.RESERVE_PUB_REUSE -> throw conflict(
+ "Reserve pub. already used",
+ TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT
)
- } 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
+ WithdrawalSelectionResult.ACCOUNT_NOT_FOUND -> throw conflict(
+ "Account ${req.selected_exchange} not found",
+ TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
)
- } else null
- val resp = BankWithdrawalOperationPostResponse(
- transfer_done = op.confirmationDone, confirm_transfer_url = confirmUrl
- )
- call.respond(resp)
+ WithdrawalSelectionResult.ACCOUNT_IS_NOT_EXCHANGE -> throw conflict(
+ "Account ${req.selected_exchange} is not an exchange",
+ TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT
+ )
+ WithdrawalSelectionResult.SUCCESS -> {
+ val confirmUrl: String? = if (ctx.spaCaptchaURL !== null && !confirmationDone) {
+ getWithdrawalConfirmUrl(
+ baseUrl = ctx.spaCaptchaURL,
+ wopId = wopid
+ )
+ } else null
+ call.respond(BankWithdrawalOperationPostResponse(
+ transfer_done = confirmationDone, confirm_transfer_url = confirmUrl
+ ))
+ }
+ }
}
} \ 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
index 5dcbac11..b379ef0b 100644
--- a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
+++ b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt
@@ -1048,19 +1048,36 @@ class Database(dbConfig: String, private val bankCurrency: String): java.io.Clos
opUuid: UUID,
exchangePayto: IbanPayTo,
reservePub: EddsaPublicKey
- ): Boolean = conn { conn ->
+ ): Pair<WithdrawalSelectionResult, Boolean> = conn { conn ->
val subject = IncomingTxMetadata(reservePub).encode()
val stmt = conn.prepareStatement("""
- UPDATE taler_withdrawal_operations
- SET selected_exchange_payto = ?, reserve_pub = ?, subject = ?, selection_done = true
- WHERE withdrawal_uuid=?
+ SELECT
+ out_no_op,
+ out_already_selected,
+ out_reserve_pub_reuse,
+ out_account_not_found,
+ out_account_is_not_exchange,
+ out_confirmation_done
+ FROM select_taler_withdrawal(?, ?, ?, ?);
"""
)
- stmt.setString(1, exchangePayto.canonical)
+ stmt.setObject(1, opUuid)
stmt.setBytes(2, reservePub.raw)
stmt.setString(3, subject)
- stmt.setObject(4, opUuid)
- stmt.executeUpdateViolation()
+ stmt.setString(4, exchangePayto.canonical)
+ stmt.executeQuery().use {
+ val status = when {
+ !it.next() ->
+ throw internalServerError("No result from DB procedure select_taler_withdrawal")
+ it.getBoolean("out_no_op") -> WithdrawalSelectionResult.OP_NOT_FOUND
+ it.getBoolean("out_already_selected") -> WithdrawalSelectionResult.ALREADY_SELECTED
+ it.getBoolean("out_reserve_pub_reuse") -> WithdrawalSelectionResult.RESERVE_PUB_REUSE
+ it.getBoolean("out_account_not_found") -> WithdrawalSelectionResult.ACCOUNT_NOT_FOUND
+ it.getBoolean("out_account_is_not_exchange") -> WithdrawalSelectionResult.ACCOUNT_IS_NOT_EXCHANGE
+ else -> WithdrawalSelectionResult.SUCCESS
+ }
+ Pair(status, it.getBoolean("out_confirmation_done"))
+ }
}
/**
@@ -1506,6 +1523,16 @@ enum class WithdrawalCreationResult {
BALANCE_INSUFFICIENT
}
+/** Result status of withdrawal operation selection */
+enum class WithdrawalSelectionResult {
+ SUCCESS,
+ OP_NOT_FOUND,
+ ALREADY_SELECTED,
+ RESERVE_PUB_REUSE,
+ ACCOUNT_NOT_FOUND,
+ ACCOUNT_IS_NOT_EXCHANGE
+}
+
/**
* This type communicates the result of a database operation
* to confirm one withdrawal operation.
diff --git a/bank/src/test/kotlin/BankIntegrationApiTest.kt b/bank/src/test/kotlin/BankIntegrationApiTest.kt
index 9f8726a6..b5dab990 100644
--- a/bank/src/test/kotlin/BankIntegrationApiTest.kt
+++ b/bank/src/test/kotlin/BankIntegrationApiTest.kt
@@ -7,6 +7,7 @@ import io.ktor.server.testing.*
import kotlinx.serialization.json.*
import kotlinx.coroutines.*
import net.taler.wallet.crypto.Base32Crockford
+import net.taler.common.errorcodes.TalerErrorCode
import org.junit.Test
import tech.libeufin.bank.*
import tech.libeufin.util.CryptoUtil
@@ -17,39 +18,103 @@ import kotlin.test.*
import randHashCode
class BankIntegrationApiTest {
- // Selecting withdrawal details from the Integration API endpoint.
+ // GET /taler-integration/config
@Test
- fun intSelect() = bankSetup { db ->
- val uuid = UUID.randomUUID()
- // insert new.
- assertEquals(WithdrawalCreationResult.SUCCESS, db.talerWithdrawalCreate(
- opUUID = uuid,
- walletAccountUsername = "merchant",
- amount = TalerAmount(1, 0, "KUDOS")
- ))
-
- val r = client.post("/taler-integration/withdrawal-operation/${uuid}") {
- jsonBody(BankWithdrawalOperationPostRequest(
- reserve_pub = randEddsaPublicKey(),
- selected_exchange = IbanPayTo("payto://iban/ABC123")
- ))
- }.assertOk()
- println(r.bodyAsText())
+ fun config() = bankSetup { _ ->
+ client.get("/taler-integration/config").assertOk()
}
- // Showing withdrawal details from the Integrtion API endpoint.
+ // GET /taler-integration/withdrawal-operation/UUID
@Test
- fun intGet() = bankSetup { db ->
- val uuid = UUID.randomUUID()
- // insert new.
- assertEquals(WithdrawalCreationResult.SUCCESS, db.talerWithdrawalCreate(
- opUUID = uuid,
- walletAccountUsername = "merchant",
- amount = TalerAmount(1, 0, "KUDOS")
- ))
+ fun get() = bankSetup { _ ->
+ // Check OK
+ client.post("/accounts/merchant/withdrawals") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json { "amount" to "KUDOS:9" })
+ }.assertOk().run {
+ val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText())
+ val uuid = resp.taler_withdraw_uri.split("/").last()
+ client.get("/taler-integration/withdrawal-operation/${uuid}")
+ .assertOk()
+ }
+
+ // Check unknown
+ client.get("/taler-integration/withdrawal-operation/${UUID.randomUUID()}")
+ .assertNotFound()
+
+ // Check bad UUID
+ client.get("/taler-integration/withdrawal-operation/chocolate")
+ .assertBadRequest()
+ }
+
+ // POST /taler-integration/withdrawal-operation/UUID
+ @Test
+ fun select() = bankSetup { _ ->
+ val reserve_pub = randEddsaPublicKey()
+ val req = json {
+ "reserve_pub" to reserve_pub
+ "selected_exchange" to IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ")
+ }
+
+ // Check bad UUID
+ client.post("/taler-integration/withdrawal-operation/chocolate") {
+ jsonBody(req)
+ }.assertBadRequest()
+
+ // Check unknown
+ client.post("/taler-integration/withdrawal-operation/${UUID.randomUUID()}") {
+ jsonBody(req)
+ }.assertNotFound()
- val r = client.get("/taler-integration/withdrawal-operation/${uuid}").assertOk()
- println(r.bodyAsText())
+ client.post("/accounts/merchant/withdrawals") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json { "amount" to "KUDOS:1" })
+ }.assertOk().run {
+ val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText())
+ val uuid = resp.taler_withdraw_uri.split("/").last()
+
+ // Check OK
+ client.post("/taler-integration/withdrawal-operation/${uuid}") {
+ jsonBody(req)
+ }.assertOk()
+ // Check idempotence
+ client.post("/taler-integration/withdrawal-operation/${uuid}") {
+ jsonBody(req)
+ }.assertOk()
+ // Check already selected
+ client.post("/taler-integration/withdrawal-operation/${uuid}") {
+ jsonBody(json(req) {
+ "reserve_pub" to randEddsaPublicKey()
+ })
+ }.assertConflict().assertErr(TalerErrorCode.TALER_EC_BANK_WITHDRAWAL_OPERATION_RESERVE_SELECTION_CONFLICT)
+ }
+
+ client.post("/accounts/merchant/withdrawals") {
+ basicAuth("merchant", "merchant-password")
+ jsonBody(json { "amount" to "KUDOS:1" })
+ }.assertOk().run {
+ val resp = Json.decodeFromString<BankAccountCreateWithdrawalResponse>(bodyAsText())
+ val uuid = resp.taler_withdraw_uri.split("/").last()
+
+ // Check reserve_pub_reuse
+ client.post("/taler-integration/withdrawal-operation/${uuid}") {
+ jsonBody(req)
+ }.assertConflict().assertErr(TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT)
+ // Check unknown account
+ client.post("/taler-integration/withdrawal-operation/${uuid}") {
+ jsonBody(json {
+ "reserve_pub" to randEddsaPublicKey()
+ "selected_exchange" to IbanPayTo("payto://iban/UNKNOWN-IBAN-XYZ")
+ })
+ }.assertConflict().assertErr(TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT)
+ // Check account not exchange
+ client.post("/taler-integration/withdrawal-operation/${uuid}") {
+ jsonBody(json {
+ "reserve_pub" to randEddsaPublicKey()
+ "selected_exchange" to IbanPayTo("payto://iban/MERCHANT-IBAN-XYZ")
+ })
+ }.assertConflict().assertErr(TalerErrorCode.TALER_EC_BANK_UNKNOWN_ACCOUNT)
+ }
}
// Testing withdrawal abort
@@ -64,7 +129,9 @@ class BankIntegrationApiTest {
))
val op = db.talerWithdrawalGet(uuid)
assert(op?.aborted == false)
- assert(db.talerWithdrawalSetDetails(uuid, IbanPayTo("payto://iban/exchange-payto"), randEddsaPublicKey()))
+ assertEquals(WithdrawalSelectionResult.SUCCESS,
+ db.talerWithdrawalSetDetails(uuid, IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ"), randEddsaPublicKey()).first
+ )
client.post("/withdrawals/${uuid}/abort") {
basicAuth("merchant", "merchant-password")
@@ -100,11 +167,13 @@ class BankIntegrationApiTest {
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 = randEddsaPublicKey()
- ))
+ assertEquals(WithdrawalSelectionResult.SUCCESS,
+ db.talerWithdrawalSetDetails(
+ opUuid = uuid,
+ exchangePayto = IbanPayTo("payto://iban/EXCHANGE-IBAN-XYZ"),
+ reservePub = randEddsaPublicKey()
+ ).first
+ )
// Starting the bank and POSTing as Foo to /confirm the operation.
client.post("/withdrawals/${uuid}/confirm") {
diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt
index b62cafa6..549a489b 100644
--- a/bank/src/test/kotlin/DatabaseTest.kt
+++ b/bank/src/test/kotlin/DatabaseTest.kt
@@ -289,11 +289,11 @@ class DatabaseTest {
val op = db.talerWithdrawalGet(uuid)
assert(op?.walletBankAccount == 2L && op.withdrawalUuid == uuid)
// Setting the details.
- assert(db.talerWithdrawalSetDetails(
+ assertEquals(WithdrawalSelectionResult.SUCCESS, db.talerWithdrawalSetDetails(
opUuid = uuid,
exchangePayto = IbanPayTo("payto://iban/FOO-IBAN-XYZ"),
reservePub = randEddsaPublicKey()
- ))
+ ).first)
val opSelected = db.talerWithdrawalGet(uuid)
assert(opSelected?.selectionDone == true && !opSelected.confirmationDone)
assert(db.talerWithdrawalConfirm(uuid, Instant.now()) == WithdrawalConfirmationResult.SUCCESS)
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
index cc0fcea7..3711bd2c 100644
--- a/bank/src/test/kotlin/helpers.kt
+++ b/bank/src/test/kotlin/helpers.kt
@@ -5,6 +5,7 @@ import io.ktor.server.testing.*
import kotlinx.coroutines.*
import kotlinx.serialization.json.*
import net.taler.wallet.crypto.Base32Crockford
+import net.taler.common.errorcodes.TalerErrorCode
import kotlin.test.*
import tech.libeufin.bank.*
import java.io.ByteArrayOutputStream
@@ -105,9 +106,20 @@ fun HttpResponse.assertStatus(status: HttpStatusCode): HttpResponse {
fun HttpResponse.assertOk(): HttpResponse = assertStatus(HttpStatusCode.OK)
fun HttpResponse.assertCreated(): HttpResponse = assertStatus(HttpStatusCode.Created)
fun HttpResponse.assertNoContent(): HttpResponse = assertStatus(HttpStatusCode.NoContent)
+fun HttpResponse.assertNotFound(): HttpResponse = assertStatus(HttpStatusCode.NotFound)
fun HttpResponse.assertUnauthorized(): HttpResponse = assertStatus(HttpStatusCode.Unauthorized)
+fun HttpResponse.assertConflict(): HttpResponse = assertStatus(HttpStatusCode.Conflict)
fun HttpResponse.assertBadRequest(): HttpResponse = assertStatus(HttpStatusCode.BadRequest)
+
+suspend fun HttpResponse.assertErr(code: TalerErrorCode): HttpResponse {
+ val err = Json.decodeFromString<TalerError>(bodyAsText())
+ assertEquals(code.code, err.code)
+ return this
+}
+
+
+
fun BankTransactionResult.assertSuccess() {
assertEquals(BankTransactionResult.SUCCESS, this)
}
diff --git a/database-versioning/procedures.sql b/database-versioning/procedures.sql
index cff7ceaf..77d3afa8 100644
--- a/database-versioning/procedures.sql
+++ b/database-versioning/procedures.sql
@@ -585,6 +585,68 @@ INSERT INTO taler_withdrawal_operations
VALUES (in_withdrawal_uuid, account_id, in_amount);
END $$;
+CREATE OR REPLACE FUNCTION select_taler_withdrawal(
+ IN in_withdrawal_uuid uuid,
+ IN in_reserve_pub BYTEA,
+ IN in_subject TEXT,
+ IN in_selected_exchange_payto TEXT,
+ -- Error status
+ OUT out_no_op BOOLEAN,
+ OUT out_already_selected BOOLEAN,
+ OUT out_reserve_pub_reuse BOOLEAN,
+ OUT out_account_not_found BOOLEAN,
+ OUT out_account_is_not_exchange BOOLEAN,
+ -- Success return
+ OUT out_confirmation_done BOOLEAN
+)
+LANGUAGE plpgsql AS $$
+DECLARE
+not_selected BOOLEAN;
+BEGIN
+-- Check for conflict and idempotence
+SELECT
+ NOT selection_done, confirmation_done,
+ selection_done
+ AND (selected_exchange_payto != in_selected_exchange_payto OR reserve_pub != in_reserve_pub)
+ INTO not_selected, out_confirmation_done, out_already_selected
+ FROM taler_withdrawal_operations
+ WHERE withdrawal_uuid=in_withdrawal_uuid;
+IF NOT FOUND THEN
+ out_no_op=TRUE;
+ RETURN;
+ELSIF out_already_selected THEN
+ RETURN;
+END IF;
+
+IF NOT out_confirmation_done AND not_selected THEN
+ -- Check reserve_pub reuse
+ SELECT true FROM taler_exchange_incoming WHERE reserve_pub = in_reserve_pub
+ UNION ALL
+ SELECT true FROM taler_withdrawal_operations WHERE reserve_pub = in_reserve_pub
+ INTO out_reserve_pub_reuse;
+ IF out_reserve_pub_reuse THEN
+ RETURN;
+ END IF;
+ -- Check exchange account
+ SELECT NOT is_taler_exchange
+ INTO out_account_is_not_exchange
+ FROM bank_accounts
+ WHERE internal_payto_uri=in_selected_exchange_payto;
+ IF NOT FOUND THEN
+ out_account_not_found=TRUE;
+ RETURN;
+ ELSIF out_account_is_not_exchange THEN
+ RETURN;
+ END IF;
+
+ -- Update withdrawal operation
+ UPDATE taler_withdrawal_operations
+ SET selected_exchange_payto=in_selected_exchange_payto, reserve_pub=in_reserve_pub, subject=in_subject, selection_done=true
+ WHERE withdrawal_uuid=in_withdrawal_uuid;
+END IF;
+END $$;
+
+
CREATE OR REPLACE FUNCTION confirm_taler_withdrawal(
IN in_withdrawal_uuid uuid,
IN in_confirmation_date BIGINT,