libeufin

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

commit 46e4838f80db6f0b086c579c4bede065fc630025
parent cbdabfad8102bc93179591fd6dfe10c153d00b1d
Author: MS <ms@taler.net>
Date:   Fri, 29 Sep 2023 09:51:23 +0200

Stop using longs to manipulate time.

The application uses now Instant and Duration deferring
their conversion to Long right before storing the time
information to the database.

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt | 12++++++------
Mbank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt | 16+++++++---------
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 50++++++++++++++++++++++++++++++++++++--------------
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 6++++--
Mbank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt | 11++++++-----
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 9+++------
Mbank/src/test/kotlin/DatabaseTest.kt | 19++++++++++---------
Mbank/src/test/kotlin/LibeuFinApiTest.kt | 9+++++----
Mutil/src/main/kotlin/time.kt | 27++++++++++++++++++++++++---
9 files changed, 101 insertions(+), 58 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt @@ -245,8 +245,8 @@ data class BearerToken( val content: ByteArray, val scope: TokenScope, val isRefreshable: Boolean = false, - val creationTime: Long, - val expirationTime: Long, + val creationTime: Instant, + val expirationTime: Instant, /** * Serial ID of the database row that hosts the bank customer * that is associated with this token. NOTE: if the token is @@ -270,7 +270,7 @@ data class BankInternalTransaction( val debtorAccountId: Long, val subject: String, val amount: TalerAmount, - val transactionDate: Long, + val transactionDate: Instant, val accountServicerReference: String = "not used", // ISO20022 val endToEndId: String = "not used", // ISO20022 val paymentInformationId: String = "not used" // ISO20022 @@ -288,7 +288,7 @@ data class BankAccountTransaction( val debtorName: String, val subject: String, val amount: TalerAmount, - val transactionDate: Long, // microseconds + val transactionDate: Instant, /** * Is the transaction debit, or credit for the * bank account pointed by this object? @@ -338,8 +338,8 @@ data class Cashout( val sellAtRatio: Int, val sellOutFee: TalerAmount, val subject: String, - val creationTime: Long, - val tanConfirmationTime: Long? = null, + val creationTime: Instant, + val tanConfirmationTime: Instant? = null, val tanChannel: TanChannel, val tanCode: String, val bankAccount: Long, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/CorebankApiHandlers.kt @@ -72,10 +72,8 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { val token = BearerToken( bankCustomer = customerDbRow, content = tokenBytes, - creationTime = creationTime.toDbMicros() - ?: throw internalServerError("Could not get micros out of token creationTime Instant."), - expirationTime = expirationTimestamp.toDbMicros() - ?: throw internalServerError("Could not get micros out of token expirationTime Instant."), + creationTime = creationTime, + expirationTime = expirationTimestamp, scope = req.scope, isRefreshable = req.refreshable ) @@ -179,7 +177,7 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { debtorAccountId = adminBankAccount.expectRowId(), amount = bonusAmount, subject = "Registration bonus.", - transactionDate = getNowUs() + transactionDate = Instant.now() ) when (db.bankTransactionCreate(adminPaysBonus)) { Database.BankTransactionResult.NO_CREDITOR -> throw internalServerError("Bonus impossible: creditor not found, despite its recent creation.") @@ -306,7 +304,7 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { // to the selected state _and_ wire the funds to the exchange. // Note: 'when' helps not to omit more result codes, should more // be added. - when (db.talerWithdrawalConfirm(op.withdrawalUuid, getNowUs())) { + when (db.talerWithdrawalConfirm(op.withdrawalUuid, Instant.now())) { WithdrawalConfirmationResult.BALANCE_INSUFFICIENT -> throw conflict( "Insufficient funds", @@ -358,7 +356,7 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { subject = it.subject, amount = it.amount.toString(), direction = it.direction, - date = TalerProtocolTimestamp.fromMicroseconds(it.transactionDate), + date = TalerProtocolTimestamp(it.transactionDate), row_id = it.dbRowId ?: throw internalServerError( "Transaction timestamped with '${it.transactionDate}' did not have row ID" ) @@ -393,7 +391,7 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { creditorAccountId = creditorCustomerData.owningCustomerId, subject = subject, amount = amount, - transactionDate = getNowUs() + transactionDate = Instant.now() ) val res = db.bankTransactionCreate(dbInstructions) when (res) { @@ -433,7 +431,7 @@ fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { amount = "${tx.amount.currency}:${tx.amount.value}.${tx.amount.frac}", creditor_payto_uri = tx.creditorPaytoUri, debtor_payto_uri = tx.debtorPaytoUri, - date = TalerProtocolTimestamp.fromMicroseconds(tx.transactionDate), + date = TalerProtocolTimestamp(tx.transactionDate), direction = tx.direction, subject = tx.subject, row_id = txRowId diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -24,11 +24,14 @@ import org.postgresql.jdbc.PgConnection import org.slf4j.Logger import org.slf4j.LoggerFactory import tech.libeufin.util.getJdbcConnectionFromPg +import tech.libeufin.util.microsToJavaInstant +import tech.libeufin.util.toDbMicros import java.io.File import java.sql.DriverManager import java.sql.PreparedStatement import java.sql.ResultSet import java.sql.SQLException +import java.time.Instant import java.util.* import kotlin.math.abs @@ -41,6 +44,25 @@ fun BankAccountTransaction.expectRowId(): Long = this.dbRowId ?: throw internalS private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.Database") +/** + * This error occurs in case the timestamp took by the bank for some + * event could not be converted in microseconds. Note: timestamp are + * taken via the Instant.now(), then converted to nanos, and then divided + * by 1000 to obtain the micros. + * + * It could be that a legitimate timestamp overflows in the process of + * being converted to micros - as described above. In the case of a timestamp, + * the fault lies to the bank, because legitimate timestamps must (at the + * time of writing!) go through the conversion to micros. + * + * On the other hand (and for the sake of completeness), in the case of a + * timestamp that was calculated after a client-submitted duration, the overflow + * lies to the client, because they must have specified a gigantic amount of time + * that overflew the conversion to micros and should simply have specified "forever". + */ +private fun faultyTimestampByBank() = internalServerError("Bank took overflowing timestamp") +private fun faultyDurationByClient() = badRequest("Overflowing duration, please specify 'forever' instead.") + fun initializeDatabaseTables(dbConfig: String, sqlDir: String) { logger.info("doing DB initialization, sqldir $sqlDir, dbConfig $dbConfig") val jdbcConnStr = getJdbcConnectionFromPg(dbConfig) @@ -299,8 +321,8 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { (?, ?, ?, ?::token_scope_enum, ?, ?) """) stmt.setBytes(1, token.content) - stmt.setLong(2, token.creationTime) - stmt.setLong(3, token.expirationTime) + stmt.setLong(2, token.creationTime.toDbMicros() ?: throw faultyTimestampByBank()) + stmt.setLong(3, token.expirationTime.toDbMicros() ?: throw faultyDurationByClient()) stmt.setString(4, token.scope.name) stmt.setLong(5, token.bankCustomer) stmt.setBoolean(6, token.isRefreshable) @@ -323,8 +345,8 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { if (!it.next()) return null return BearerToken( content = token, - creationTime = it.getLong("creation_time"), - expirationTime = it.getLong("expiration_time"), + creationTime = it.getLong("creation_time").microsToJavaInstant() ?: throw faultyTimestampByBank(), + expirationTime = it.getLong("expiration_time").microsToJavaInstant() ?: throw faultyDurationByClient(), bankCustomer = it.getLong("bank_customer"), scope = it.getString("scope").run { if (this == TokenScope.readwrite.name) return@run TokenScope.readwrite @@ -511,7 +533,7 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { stmt.setString(3, tx.subject) stmt.setLong(4, tx.amount.value) stmt.setInt(5, tx.amount.frac) - stmt.setLong(6, tx.transactionDate) + stmt.setLong(6, tx.transactionDate.toDbMicros() ?: throw faultyTimestampByBank()) stmt.setString(7, tx.accountServicerReference) stmt.setString(8, tx.paymentInformationId) stmt.setString(9, tx.endToEndId) @@ -603,7 +625,7 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { bankAccountId = it.getLong("bank_account_id"), paymentInformationId = it.getString("payment_information_id"), subject = it.getString("subject"), - transactionDate = it.getLong("transaction_date") + transactionDate = it.getLong("transaction_date").microsToJavaInstant() ?: throw faultyTimestampByBank() ) } } @@ -699,7 +721,7 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { bankAccountId = it.getLong("bank_account_id"), paymentInformationId = it.getString("payment_information_id"), subject = it.getString("subject"), - transactionDate = it.getLong("transaction_date"), + transactionDate = it.getLong("transaction_date").microsToJavaInstant() ?: throw faultyTimestampByBank(), dbRowId = it.getLong("bank_transaction_id") )) } while (it.next()) @@ -815,7 +837,7 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { */ fun talerWithdrawalConfirm( opUuid: UUID, - timestamp: Long, + timestamp: Instant, accountServicerReference: String = "NOT-USED", endToEndId: String = "NOT-USED", paymentInfId: String = "NOT-USED" @@ -831,7 +853,7 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { """ ) stmt.setObject(1, opUuid) - stmt.setLong(2, timestamp) + stmt.setLong(2, timestamp.toDbMicros() ?: throw faultyTimestampByBank()) stmt.setString(3, accountServicerReference) stmt.setString(4, endToEndId) stmt.setString(5, paymentInfId) @@ -898,7 +920,7 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { stmt.setLong(10, op.sellOutFee.value) stmt.setInt(11, op.sellOutFee.frac) stmt.setString(12, op.subject) - stmt.setLong(13, op.creationTime) + stmt.setLong(13, op.creationTime.toDbMicros() ?: throw faultyTimestampByBank()) stmt.setString(14, op.tanChannel.name) stmt.setString(15, op.tanCode) stmt.setLong(16, op.bankAccount) @@ -1008,7 +1030,7 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { credit_payto_uri = it.getString("credit_payto_uri"), cashoutCurrency = it.getString("cashout_currency"), cashoutUuid = opUuid, - creationTime = it.getLong("creation_time"), + creationTime = it.getLong("creation_time").microsToJavaInstant() ?: throw faultyTimestampByBank(), sellAtRatio = it.getInt("sell_at_ratio"), sellOutFee = TalerAmount( value = it.getLong("sell_out_fee_val"), @@ -1028,7 +1050,7 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { localTransaction = it.getLong("local_transaction"), tanConfirmationTime = it.getLong("tan_confirmation_time").run { if (this == 0L) return@run null - return@run this + return@run this.microsToJavaInstant() ?: throw faultyTimestampByBank() } ) } @@ -1111,7 +1133,7 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { fun talerTransferCreate( req: TransferRequest, exchangeBankAccountId: Long, - timestamp: Long, + timestamp: Instant, acctSvcrRef: String = "not used", pmtInfId: String = "not used", endToEndId: String = "not used", @@ -1144,7 +1166,7 @@ class Database(private val dbConfig: String, private val bankCurrency: String) { stmt.setString(5, req.exchange_base_url) stmt.setString(6, req.credit_account) stmt.setLong(7, exchangeBankAccountId) - stmt.setLong(8, timestamp) + stmt.setLong(8, timestamp.toDbMicros() ?: throw faultyTimestampByBank()) stmt.setString(9, acctSvcrRef) stmt.setString(10, pmtInfId) stmt.setString(11, endToEndId) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -132,8 +132,7 @@ object TalerProtocolTimestampSerializer : KSerializer<TalerProtocolTimestamp> { encodeStringElement(descriptor, 0, "never") return@encodeStructure } - val ts = value.t_s.toDbMicros() ?: throw internalServerError("Could not serialize timestamp") - encodeLongElement(descriptor, 0, ts) + encodeLongElement(descriptor, 0, value.t_s.epochSecond) } } @@ -368,6 +367,9 @@ fun Application.corebankWebApp(db: Database, ctx: BankApplicationContext) { */ exception<LibeufinBankException> { call, cause -> logger.error(cause.talerError.hint) + // Stacktrace if bank's fault + if (cause.httpStatus.toString().startsWith('5')) + cause.printStackTrace() call.respond( status = cause.httpStatus, message = cause.talerError diff --git a/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/WireGatewayApiHandlers.kt @@ -27,6 +27,7 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import net.taler.common.errorcodes.TalerErrorCode +import java.time.Instant fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) { get("/taler-wire-gateway/config") { @@ -59,7 +60,7 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) IncomingReserveTransaction( row_id = it.expectRowId(), amount = it.amount.toString(), - date = TalerProtocolTimestamp.fromMicroseconds(it.transactionDate), + date = TalerProtocolTimestamp(it.transactionDate), debit_account = it.debtorPaytoUri, reserve_pub = it.subject ) @@ -101,7 +102,7 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) throw badRequest("Currency mismatch: $internalCurrency vs ${req.amount.currency}") val exchangeBankAccount = db.bankAccountGetFromOwnerId(c.expectRowId()) ?: throw internalServerError("Exchange does not have a bank account") - val transferTimestamp = getNowUs() + val transferTimestamp = Instant.now() val dbRes = db.talerTransferCreate( req = req, exchangeBankAccountId = exchangeBankAccount.expectRowId(), @@ -121,7 +122,7 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) ?: throw internalServerError("Database did not return the debit tx row ID") call.respond( TransferResponse( - timestamp = TalerProtocolTimestamp.fromMicroseconds(transferTimestamp), + timestamp = TalerProtocolTimestamp(transferTimestamp), row_id = debitRowId ) ) @@ -151,7 +152,7 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) ) val exchangeAccount = db.bankAccountGetFromOwnerId(c.expectRowId()) ?: throw internalServerError("exchange bank account not found, despite it's a customer") - val txTimestamp = getNowUs() + val txTimestamp = Instant.now() val op = BankInternalTransaction( debtorAccountId = walletAccount.expectRowId(), amount = amount, @@ -174,7 +175,7 @@ fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) call.respond( AddIncomingResponse( row_id = rowId, - timestamp = TalerProtocolTimestamp.fromMicroseconds(txTimestamp) + timestamp = TalerProtocolTimestamp(txTimestamp) ) ) return@post diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -118,7 +118,7 @@ fun doTokenAuth( logger.error("Auth token not found") return null } - if (maybeToken.expirationTime - getNowUs() < 0) { + if (maybeToken.expirationTime.isBefore(Instant.now())) { logger.error("Auth token is expired") return null } @@ -438,7 +438,4 @@ fun maybeCreateAdminAccount(db: Database, ctx: BankApplicationContext): Boolean } } return true -} - -fun getNowUs(): Long = Instant.now().toDbMicros() - ?: throw internalServerError("Could not get micros out of Instant.now()") -\ No newline at end of file +} +\ No newline at end of file diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -20,6 +20,7 @@ import org.junit.Test import tech.libeufin.bank.* import tech.libeufin.util.CryptoUtil +import java.time.Instant import java.util.Random import java.util.UUID @@ -37,7 +38,7 @@ fun genTx( accountServicerReference = "acct-svcr-ref", endToEndId = "end-to-end-id", paymentInformationId = "pmtinfid", - transactionDate = 100000L + transactionDate = Instant.now() ) class DatabaseTest { @@ -122,7 +123,7 @@ class DatabaseTest { val res = db.talerTransferCreate( req = exchangeReq, exchangeBankAccountId = 1L, - timestamp = getNowUs() + timestamp = Instant.now() ) assert(res.txResult == Database.BankTransactionResult.SUCCESS) } @@ -135,8 +136,8 @@ class DatabaseTest { val token = BearerToken( bankCustomer = 1L, content = tokenBytes, - creationTime = getNowUs(), - expirationTime = getNowUs(), + creationTime = Instant.now(), + expirationTime = Instant.now().plusSeconds(10), scope = TokenScope.readonly ) assert(db.bearerTokenGet(token.content) == null) @@ -196,7 +197,7 @@ class DatabaseTest { accountServicerReference = "acct-svcr-ref", endToEndId = "end-to-end-id", paymentInformationId = "pmtinfid", - transactionDate = 100000L + transactionDate = Instant.now() ) val barPays = db.bankTransactionCreate(barPaysFoo) assert(barPays == Database.BankTransactionResult.SUCCESS) @@ -272,7 +273,7 @@ class DatabaseTest { )) val opSelected = db.talerWithdrawalGet(uuid) assert(opSelected?.selectionDone == true && !opSelected.confirmationDone) - assert(db.talerWithdrawalConfirm(uuid, 1L) == WithdrawalConfirmationResult.SUCCESS) + assert(db.talerWithdrawalConfirm(uuid, Instant.now()) == WithdrawalConfirmationResult.SUCCESS) // Finally confirming the operation (means customer wired funds to the exchange.) assert(db.talerWithdrawalGet(uuid)?.confirmationDone == true) } @@ -315,10 +316,10 @@ class DatabaseTest { sellOutFee = TalerAmount(0, 44, currency), credit_payto_uri = "IBAN", cashoutCurrency = "KUDOS", - creationTime = 3L, + creationTime = Instant.now(), subject = "31st", tanChannel = TanChannel.sms, - tanCode = "secret", + tanCode = "secret" ) val fooId = db.customerCreate(customerFoo) assert(fooId != null) @@ -343,7 +344,7 @@ class DatabaseTest { accountServicerReference = "acct-svcr-ref", endToEndId = "end-to-end-id", paymentInformationId = "pmtinfid", - transactionDate = 100000L + transactionDate = Instant.now() ) ) == Database.BankTransactionResult.SUCCESS) // Confirming the cash-out diff --git a/bank/src/test/kotlin/LibeuFinApiTest.kt b/bank/src/test/kotlin/LibeuFinApiTest.kt @@ -10,6 +10,7 @@ import tech.libeufin.bank.* import tech.libeufin.util.CryptoUtil import java.time.Duration import java.time.Instant +import java.time.temporal.ChronoUnit import kotlin.random.Random class LibeuFinApiTest { @@ -179,8 +180,8 @@ class LibeuFinApiTest { // Checking that the token lifetime defaulted to 24 hours. val newTokObj = Json.decodeFromString<TokenSuccessResponse>(newTok.bodyAsText()) val newTokDb = db.bearerTokenGet(Base32Crockford.decode(newTokObj.access_token)) - val lifeTime = newTokDb!!.expirationTime - newTokDb.creationTime - assert(Duration.ofHours(24).seconds * 1000000 == lifeTime) + val lifeTime = Duration.between(newTokDb!!.creationTime, newTokDb.expirationTime) + assert(lifeTime == Duration.ofDays(1)) // foo tries on bar endpoint val r = client.post("/accounts/bar/token") { expectSuccess = false @@ -195,9 +196,9 @@ class LibeuFinApiTest { content = fooTok, bankCustomer = 1L, // only foo exists. scope = TokenScope.readonly, - creationTime = getNowUs(), + creationTime = Instant.now(), isRefreshable = true, - expirationTime = getNowUs() + (Duration.ofHours(1).toMillis() * 1000) + expirationTime = Instant.now().plus(1, ChronoUnit.DAYS) ) ) ) diff --git a/util/src/main/kotlin/time.kt b/util/src/main/kotlin/time.kt @@ -20,6 +20,7 @@ package tech.libeufin.util import java.time.* +import java.time.temporal.ChronoUnit import java.util.concurrent.TimeUnit /** @@ -28,13 +29,17 @@ import java.util.concurrent.TimeUnit * if one arithmetic overflow occurred. */ private fun Instant.toNanos(): Long? { - val oneSecNanos = TimeUnit.SECONDS.toNanos(1) + val oneSecNanos = ChronoUnit.SECONDS.duration.toNanos() val nanoBase: Long = this.epochSecond * oneSecNanos - if (nanoBase != 0L && nanoBase / this.epochSecond != oneSecNanos) + if (nanoBase != 0L && nanoBase / this.epochSecond != oneSecNanos) { + logger.error("Multiplication overflow: could not convert Instant to nanos.") return null + } val res = nanoBase + this.nano - if (res < nanoBase) + if (res < nanoBase) { + logger.error("Addition overflow: could not convert Instant to nanos.") return null + } return res } @@ -55,4 +60,20 @@ fun Instant.toDbMicros(): Long? { return Long.MAX_VALUE val nanos = this.toNanos() ?: return null return nanos / 1000L +} + +/** + * This helper is typically used to convert a timestamp expressed + * in microseconds from the DB back to the Web application. In case + * of _any_ error, it logs it and returns null. + */ +fun Long.microsToJavaInstant(): Instant? { + if (this == Long.MAX_VALUE) + return Instant.MAX + return try { + Instant.EPOCH.plus(this, ChronoUnit.MICROS) + } catch (e: Exception) { + logger.error(e.message) + return null + } } \ No newline at end of file