diff options
author | Antoine A <> | 2023-10-18 16:21:18 +0000 |
---|---|---|
committer | Antoine A <> | 2023-10-18 16:21:18 +0000 |
commit | a55388ced30d0acb9e1b21f2c68d6403afebb21a (patch) | |
tree | 555c8d96bed8a66d28e714e942d872c66bcdb682 | |
parent | 94cb68f30e1b0a6d5fdb61d1917c8f08337b1f7a (diff) | |
download | libeufin-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.kt | 68 | ||||
-rw-r--r-- | bank/src/main/kotlin/tech/libeufin/bank/Database.kt | 41 | ||||
-rw-r--r-- | bank/src/test/kotlin/BankIntegrationApiTest.kt | 137 | ||||
-rw-r--r-- | bank/src/test/kotlin/DatabaseTest.kt | 4 | ||||
-rw-r--r-- | bank/src/test/kotlin/helpers.kt | 12 | ||||
-rw-r--r-- | database-versioning/procedures.sql | 62 |
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, |