libeufin

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

commit 30d6541149f8db5cc445506ecb18a026decfb255
parent 8e45d1be81105acd292502a02c03e001a60f6327
Author: Antoine A <>
Date:   Thu,  5 Oct 2023 14:48:13 +0000

Add /taler-wire-gateway/history/outgoing

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt | 24+++++++++++++++++++++++-
Mbank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mbank/src/test/kotlin/TalerApiTest.kt | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
3 files changed, 203 insertions(+), 4 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt @@ -683,7 +683,29 @@ data class IncomingReserveTransaction( val date: TalerProtocolTimestamp, val amount: TalerAmount, val debit_account: String, // Payto of the sender. - val reserve_pub: String + val reserve_pub: EddsaPublicKey +) + +/** + * Response of a TWG /history/outgoing call. + */ +@Serializable +data class OutgoingHistory( + val outgoing_transactions: MutableList<OutgoingTransaction> = mutableListOf(), + val debit_account: String // Debitor's Payto URI. +) + +/** + * TWG's outgoinf payment record. + */ +@Serializable +data class OutgoingTransaction( + val row_id: Long, // DB row ID of the payment. + val date: TalerProtocolTimestamp, + val amount: TalerAmount, + val credit_account: String, // Payto of the receiver. + val wtid: ShortHashCode, + val exchange_base_url: String, ) /** diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt @@ -141,12 +141,19 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) if (history.isEmpty()) break; history.forEach { - val reservePub = extractReservePubFromSubject(it.subject) + val reservePub = try { + EddsaPublicKey(it.subject) + } catch (e: Exception) { + logger.debug("Not containing a reserve pub: ${it.subject}") + null + } if (reservePub == null) { // This should usually not happen in the first place, // because transactions to the exchange without a valid // reserve pub should be bounced. logger.warn("exchange account $username contains invalid incoming transaction ${it.expectRowId()}") + // Skip row + start = it.expectRowId() } else { // Register new transacation resp.incoming_transactions.add( @@ -172,6 +179,66 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) } } + get("/accounts/{USERNAME}/taler-wire-gateway/history/outgoing") { + val username = call.authCheck(TokenScope.readonly, true) + val params = getHistoryParams(call.request) + val bankAccount = call.bankAccount() + if (!bankAccount.isTalerExchange) throw forbidden("History is not related to a Taler exchange.") + + val resp = OutgoingHistory(debit_account = bankAccount.internalPaytoUri) + var start = params.start + var delta = params.delta + + // As we may ignore rows containing incorrect subjects, we may have to run several queries. + while (delta != 0L) { + val history: List<BankAccountTransaction> = db.bankTransactionGetHistory( + start = start, + delta = delta, + bankAccountId = bankAccount.expectRowId(), + withDirection = TransactionDirection.debit + ) + if (history.isEmpty()) + break; + history.forEach { + val metadata = try { + val split = it.subject.split(" ") + Pair(ShortHashCode(split[0]), split[1]) + } catch (e: Exception) { + logger.debug("Not containing metadata: ${it.subject}") + null + } + if (metadata == null) { + // This should usually not happen in the first place, + // because transactions from the exchange should be well formed + logger.warn("exchange account $username contains invalid outgoing transaction ${it.expectRowId()}") + // Skip row + start = it.expectRowId() + } else { + // Register new transacation + resp.outgoing_transactions.add( + OutgoingTransaction( + row_id = it.expectRowId(), + date = TalerProtocolTimestamp(it.transactionDate), + amount = it.amount, + credit_account = it.creditorPaytoUri, + wtid = metadata.first, + exchange_base_url = metadata.second + ) + ) + // Advance cursor + start = it.expectRowId() + if (delta < 0) delta++ else delta--; + } + } + } + + if (resp.outgoing_transactions.isEmpty()) { + call.respond(HttpStatusCode.NoContent) + } else { + call.respond(resp) + } + } + post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") { call.authCheck(TokenScope.readwrite, false); val req = call.receive<AddIncomingRequest>() diff --git a/bank/src/test/kotlin/TalerApiTest.kt b/bank/src/test/kotlin/TalerApiTest.kt @@ -12,6 +12,7 @@ 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 @@ -51,6 +52,20 @@ class TalerApiTest { cashoutCurrency = "KUDOS" ) + suspend fun transfer(db: Database, from: Long, to: BankAccount) { + db.talerTransferCreate( + req = TransferRequest( + request_uid = randHashCode(), + amount = TalerAmount(10, 0, "Kudos"), + exchange_base_url = "http://exchange.example.com/", + wtid = randShortHashCode(), + credit_account ="${stripIbanPayto(to.internalPaytoUri)}" + ), + exchangeBankAccountId = from, + timestamp = Instant.now() + ) + } + fun commonSetup(): Pair<Database, BankApplicationContext> { val db = initDb() val ctx = getTestContext() @@ -206,7 +221,6 @@ class TalerApiTest { ) ) - // Bar expects two entries in the incoming history testApplication { application { corebankWebApp(db, ctx) @@ -285,8 +299,104 @@ class TalerApiTest { assert(history.incoming_transactions[0].row_id <= 300) // testing that the row_id decreases. assert(history.incoming_transactions.windowed(2).all { (a, b) -> a.row_id > b.row_id }) + } + } + } + + + /** + * Testing the /history/outgoing call from the TWG API. + */ + @Test + fun historyOutgoing() { + val (db, ctx) = commonSetup() + // Give Bar reasonable debt allowance: + assert( + db.bankAccountSetMaxDebt( + 2L, + TalerAmount(1000000, 0, "KUDOS") + ) + ) + + testApplication { + application { + corebankWebApp(db, ctx) } - + + authRoutine(client, "/accounts/foo/taler-wire-gateway/history/outgoing", HttpMethod.Get) + + // Check error when no transactions + client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=7") { + basicAuth("bar", "secret") + }.assertStatus(HttpStatusCode.NoContent) + + // Bar pays Foo three time + repeat(3) { + transfer(db, 2, 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() + // Bar pays Foo twice, we should see five valid transactions + repeat(2) { + transfer(db, 2, bankAccountFoo) + } + + // Check ignore bogus subject + client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=7") { + basicAuth("bar", "secret") + }.assertOk().run { + val j: OutgoingHistory = Json.decodeFromString(this.bodyAsText()) + assertEquals(5, j.outgoing_transactions.size) + } + + // Check skip bogus subject + client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=5") { + basicAuth("bar", "secret") + }.assertOk().run { + val j: OutgoingHistory = Json.decodeFromString(this.bodyAsText()) + assertEquals(5, j.outgoing_transactions.size) + } + + // Testing ranges. + val mockReservePub = randShortHashCode().encoded + for (i in 1..400) + transfer(db, 2, bankAccountFoo) + + // forward range: + client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=10&start=30") { + basicAuth("bar", "secret") + }.assertOk().run { + val txt = this.bodyAsText() + val history = Json.decodeFromString<OutgoingHistory>(txt) + // testing the size is like expected. + assert(history.outgoing_transactions.size == 10) { + println("outgoing_transactions has wrong size: ${history.outgoing_transactions.size}") + println("Response was: ${txt}") + } + // testing that the first row_id is at least the 'start' query param. + assert(history.outgoing_transactions[0].row_id >= 30) + // testing that the row_id increases. + assert(history.outgoing_transactions.windowed(2).all { (a, b) -> a.row_id < b.row_id }) + } + + // backward range: + client.get("/accounts/bar/taler-wire-gateway/history/outgoing?delta=-10&start=300") { + basicAuth("bar", "secret") + }.assertOk().run { + val txt = this.bodyAsText() + val history = Json.decodeFromString<OutgoingHistory>(txt) + // testing the size is like expected. + assert(history.outgoing_transactions.size == 10) { + println("outgoing_transactions has wrong size: ${history.outgoing_transactions.size}") + println("Response was: ${txt}") + } + // testing that the first row_id is at most the 'start' query param. + assert(history.outgoing_transactions[0].row_id <= 300) + // testing that the row_id decreases. + assert(history.outgoing_transactions.windowed(2).all { (a, b) -> a.row_id > b.row_id }) + } } }