libeufin

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

commit e5c64e7b494d19e7ebdc4124fc17cdd9d104715a
parent 94c18037115a2ef8de5674616c0f242bab2a1388
Author: Florian Dold <florian@dold.me>
Date:   Fri, 22 Sep 2023 17:13:48 +0200

read configuration from file, remove config from DB

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Database.kt | 43+++++--------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mbank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt | 25+++++++------------------
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 27++++-----------------------
Mbank/src/main/kotlin/tech/libeufin/bank/talerIntegrationHandlers.kt | 11+++--------
Mbank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt | 12++++--------
Mbank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt | 17++++-------------
Mbank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt | 4++--
Mbank/src/main/kotlin/tech/libeufin/bank/types.kt | 26++++++++++++++------------
Mbank/src/test/kotlin/AmountTest.kt | 10----------
Mbank/src/test/kotlin/Common.kt | 23++++++++++++++++++++---
Mbank/src/test/kotlin/DatabaseTest.kt | 15++++-----------
Mbank/src/test/kotlin/LibeuFinApiTest.kt | 181++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mbank/src/test/kotlin/TalerApiTest.kt | 32++++++++++++++++----------------
Mdatabase-versioning/libeufin-bank-0001.sql | 9---------
Mutil/src/main/kotlin/TalerConfig.kt | 21++++++++++++++++++---
Mutil/src/test/kotlin/TalerConfigTest.kt | 2+-
17 files changed, 354 insertions(+), 252 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Database.kt b/bank/src/main/kotlin/tech/libeufin/bank/Database.kt @@ -40,11 +40,10 @@ fun BankAccountTransaction.expectRowId(): Long = this.dbRowId ?: throw internalS private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.Database") -class Database(private val dbConfig: String) { +class Database(private val dbConfig: String, private val bankCurrency: String) { private var dbConn: PgConnection? = null private var dbCtr: Int = 0 private val preparedStatements: MutableMap<String, PreparedStatement> = mutableMapOf() - private var cachedCurrency: String? = null; init { Class.forName("org.postgresql.Driver") @@ -90,42 +89,6 @@ class Database(private val dbConfig: String) { return true } - /** - * Get the currency applicable to the bank. - */ - private fun getCurrency(): String { - var myCurrency = cachedCurrency - if (myCurrency != null) { - return myCurrency - } - // FIXME: Should be retrieved from the config file instead of the DB. - myCurrency = configGet("internal_currency") - if (myCurrency == null) { - throw Error("configuration does not specify currency") - } - cachedCurrency = myCurrency - return myCurrency - } - - // CONFIG - fun configGet(configKey: String): String? { - reconnect() - val stmt = prepare("SELECT config_value FROM configuration WHERE config_key=?;") - stmt.setString(1, configKey) - val rs = stmt.executeQuery() - rs.use { - if(!it.next()) return null - return it.getString("config_value") - } - } - fun configSet(configKey: String, configValue: String) { - reconnect() - val stmt = prepare("CALL bank_set_config(TEXT(?), TEXT(?))") - stmt.setString(1, configKey) - stmt.setString(2, configValue) - stmt.execute() - } - // CUSTOMERS /** * This method INSERTs a new customer into the database and @@ -353,6 +316,10 @@ class Database(private val dbConfig: String) { return myExecute(stmt) } + private fun getCurrency(): String { + return bankCurrency + } + fun bankAccountGetFromOwnerId(ownerId: Long): BankAccount? { reconnect() val stmt = prepare(""" diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -21,6 +21,7 @@ package tech.libeufin.bank import TalerConfig +import TalerConfigError import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.context import com.github.ajalt.clikt.core.subcommands @@ -47,13 +48,11 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* import kotlinx.serialization.modules.SerializersModule import net.taler.common.errorcodes.TalerErrorCode -import net.taler.wallet.crypto.Base32Crockford import org.slf4j.Logger import org.slf4j.LoggerFactory import org.slf4j.event.Level import tech.libeufin.util.* import java.time.Duration -import java.util.Random import kotlin.system.exitProcess // GLOBALS @@ -63,6 +62,50 @@ val TOKEN_DEFAULT_DURATION_US = Duration.ofDays(1L).seconds * 1000000 /** + * Application context with the parsed configuration. + */ +data class BankApplicationContext( + /** + * Main, internal currency of the bank. + */ + val currency: String, + /** + * Restrict account registration to the administrator. + */ + val restrictRegistration: Boolean, + /** + * Cashout currency, if cashouts are supported. + */ + val cashoutCurrency: String?, + /** + * Default limit for the debt that a customer can have. + * Can be adjusted per account after account creation. + */ + val defaultCustomerDebtLimit: TalerAmount, + /** + * Debt limit of the admin account. + */ + val defaultAdminDebtLimit: TalerAmount, + /** + * If true, transfer a registration bonus from the admin + * account to the newly created account. + */ + val registrationBonusEnabled: Boolean, + /** + * Only set if registration bonus is enabled. + */ + val registrationBonus: TalerAmount?, + /** + * Exchange that the bank suggests to wallets for withdrawal. + */ + val suggestedWithdrawalExchange: String?, + /** + * Max token duration in microseconds. + */ + val maxAuthTokenDurationUs: Long, +) + +/** * This custom (de)serializer interprets the RelativeTime JSON * type. In particular, it is responsible for converting the * "forever" string into Long.MAX_VALUE. Any other numeric value @@ -157,7 +200,7 @@ fun ApplicationCall.myAuth(db: Database, requiredScope: TokenScope): Customer? { /** * Set up web server handlers for the Taler corebank API. */ -fun Application.corebankWebApp(db: Database) { +fun Application.corebankWebApp(db: Database, ctx: BankApplicationContext) { install(CallLogging) { this.level = Level.DEBUG this.logger = tech.libeufin.bank.logger @@ -266,12 +309,12 @@ fun Application.corebankWebApp(db: Database) { call.respond(Config()) return@get } - this.accountsMgmtHandlers(db) - this.tokenHandlers(db) - this.transactionsHandlers(db) + this.accountsMgmtHandlers(db, ctx) + this.tokenHandlers(db, ctx) + this.transactionsHandlers(db, ctx) this.talerWebHandlers(db) - this.talerIntegrationHandlers(db) - this.talerWireGatewayHandlers(db) + this.talerIntegrationHandlers(db, ctx) + this.talerWireGatewayHandlers(db, ctx) } } @@ -283,6 +326,88 @@ class LibeufinBankCommand : CliktCommand() { override fun run() = Unit } +fun durationFromPretty(s: String): Long { + var durationUs: Long = 0; + var currentNum = ""; + var parsingNum = true + for (c in s) { + if (c >= '0' && c <= '9') { + if (!parsingNum) { + throw Error("invalid duration, unexpected number") + } + currentNum += c + continue + } + if (c == ' ') { + if (currentNum != "") { + parsingNum = false + } + continue + } + if (currentNum == "") { + throw Error("invalid duration, missing number") + } + val n = currentNum.toInt(10) + durationUs += when (c) { + 's' -> { n * 1000000 } + 'm' -> { n * 1000000 * 60 } + 'h' -> { n * 1000000 * 60 * 60 } + 'd' -> { n * 1000000 * 60 * 60 * 24 } + else -> { throw Error("invalid duration, unsupported unit '$c'") } + } + parsingNum = true + currentNum = "" + } + return durationUs +} + +/** + * FIXME: Introduce a datatype for this instead of using Long + */ +fun TalerConfig.requireValueDuration(section: String, option: String): Long { + val durationStr = lookupValueString(section, option) + if (durationStr == null) { + throw TalerConfigError("expected amount for section $section, option $option, but config value is empty") + } + return durationFromPretty(durationStr) +} + +fun TalerConfig.requireValueAmount(section: String, option: String, currency: String): TalerAmount { + val amountStr = lookupValueString(section, option) + if (amountStr == null) { + throw TalerConfigError("expected amount for section $section, option $option, but config value is empty") + } + val amount = parseTalerAmount2(amountStr, FracDigits.EIGHT) + if (amount == null) { + throw TalerConfigError("expected amount for section $section, option $option, but amount is malformed") + } + if (amount.currency != currency) { + throw TalerConfigError( + "expected amount for section $section, option $option, but currency is wrong (got ${amount.currency} expected $currency" + ) + } + return amount +} + +/** + * Read the configuration of the bank from a config file. + * Throws an exception if the configuration is malformed. + */ +fun readBankApplicationContextFromConfig(cfg: TalerConfig): BankApplicationContext { + val currency = cfg.requireValueString("libeufin-bank", "currency") + return BankApplicationContext( + currency = currency, + restrictRegistration = cfg.lookupValueBooleanDefault("libeufin-bank", "restrict_registration", false), + cashoutCurrency = cfg.lookupValueString("libeufin-bank", "cashout_currency"), + defaultCustomerDebtLimit = cfg.requireValueAmount("libeufin-bank", "default_customer_debt_limit", currency), + registrationBonusEnabled = cfg.lookupValueBooleanDefault("libeufin-bank", "registration_bonus_enabled", false), + registrationBonus = cfg.requireValueAmount("libeufin-bank", "registration_bonus", currency), + suggestedWithdrawalExchange = cfg.lookupValueString("libeufin-bank", "suggested_withdrawal_exchange"), + defaultAdminDebtLimit = cfg.requireValueAmount("libeufin-bank", "default_admin_debt_limit", currency), + maxAuthTokenDurationUs = cfg.requireValueDuration("libeufin-bank", "max_auth_token_duration"), + ) +} + class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") { init { context { @@ -292,13 +417,14 @@ class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") override fun run() { val config = TalerConfig.load() + val ctx = readBankApplicationContextFromConfig(config) val dbConnStr = config.requireValueString("libeufin-bank-db-postgres", "config") logger.info("using database '$dbConnStr'") - val db = Database(dbConnStr) - if (!maybeCreateAdminAccount(db)) // logs provided by the helper + val db = Database(dbConnStr, ctx.currency) + if (!maybeCreateAdminAccount(db, ctx)) // logs provided by the helper exitProcess(1) embeddedServer(Netty, port = 8080) { - corebankWebApp(db) + corebankWebApp(db, ctx) }.start(wait = true) } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt @@ -19,11 +19,10 @@ private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.account * create, update, delete, show bank accounts. No histories * and wire transfers should belong here. */ -fun Routing.accountsMgmtHandlers(db: Database) { +fun Routing.accountsMgmtHandlers(db: Database, ctx: BankApplicationContext) { post("/accounts") { - // check if only admin. - val maybeOnlyAdmin = db.configGet("only_admin_registrations") - if (maybeOnlyAdmin?.lowercase() == "yes") { + // check if only admin is allowed to create new accounts + if (ctx.restrictRegistration) { val customer: Customer? = call.myAuth(db, TokenScope.readwrite) if (customer == null || customer.login != "admin") throw LibeufinBankException( @@ -84,7 +83,7 @@ fun Routing.accountsMgmtHandlers(db: Database) { phone = req.challenge_contact_data?.phone, cashoutPayto = req.cashout_payto_uri, // Following could be gone, if included in cashout_payto_uri - cashoutCurrency = db.configGet("cashout_currency"), + cashoutCurrency = ctx.cashoutCurrency, passwordHash = CryptoUtil.hashpw(req.password), ) val newCustomerRowId = db.customerCreate(newCustomer) @@ -92,10 +91,7 @@ fun Routing.accountsMgmtHandlers(db: Database) { /* Crashing here won't break data consistency between customers * and bank accounts, because of the idempotency. Client will * just have to retry. */ - val maxDebt = db.configGet("max_debt_ordinary_customers").run { - if (this == null) throw internalServerError("Max debt not configured") - parseTalerAmount(this) - } + val maxDebt = ctx.defaultCustomerDebtLimit val newBankAccount = BankAccount( hasDebt = false, internalPaytoUri = req.internal_payto_uri ?: genIbanPaytoUri(), @@ -112,15 +108,8 @@ fun Routing.accountsMgmtHandlers(db: Database) { * bonus to it. The configuration gets either a Taler amount (of the * bonus), or null if no bonus is meant to be awarded. */ - val bonusAmount = db.configGet("registration_bonus") + val bonusAmount = if (ctx.registrationBonusEnabled) ctx.registrationBonus else null if (bonusAmount != null) { - // Double-checking that the currency is correct. - val internalCurrency = db.configGet("internal_currency") - ?: throw internalServerError("Bank own currency missing in the config") - val bonusAmountObj = parseTalerAmount2(bonusAmount, FracDigits.EIGHT) - ?: throw internalServerError("Bonus amount found invalid in the config.") - if (bonusAmountObj.currency != internalCurrency) - throw internalServerError("Bonus amount has the wrong currency: ${bonusAmountObj.currency}") val adminCustomer = db.customerGetFromLogin("admin") ?: throw internalServerError("Admin customer not found") val adminBankAccount = db.bankAccountGetFromOwnerId(adminCustomer.expectRowId()) @@ -128,7 +117,7 @@ fun Routing.accountsMgmtHandlers(db: Database) { val adminPaysBonus = BankInternalTransaction( creditorAccountId = newBankAccountId, debtorAccountId = adminBankAccount.expectRowId(), - amount = bonusAmountObj, + amount = bonusAmount, subject = "Registration bonus.", transactionDate = getNowUs() ) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -354,7 +354,6 @@ fun isBalanceEnough( (normalDiff.frac > normalMaxDebt.frac)) return false return true } -fun getBankCurrency(db: Database): String = db.configGet("internal_currency") ?: throw internalServerError("Bank lacks currency") /** * Builds the taler://withdraw-URI. Such URI will serve the requests @@ -465,7 +464,7 @@ fun getHistoryParams(req: ApplicationRequest): HistoryParams { * * It returns false in case of problems, true otherwise. */ -fun maybeCreateAdminAccount(db: Database): Boolean { +fun maybeCreateAdminAccount(db: Database, ctx: BankApplicationContext): Boolean { val maybeAdminCustomer = db.customerGetFromLogin("admin") val adminCustomerId: Long = if (maybeAdminCustomer == null) { logger.debug("Creating admin's customer row") @@ -487,26 +486,8 @@ fun maybeCreateAdminAccount(db: Database): Boolean { maybeAdminCustomer.expectRowId() val maybeAdminBankAccount = db.bankAccountGetFromOwnerId(adminCustomerId) if (maybeAdminBankAccount == null) { - logger.debug("Creating admin's bank account row.") - val adminMaxDebt = db.configGet("admin_max_debt") - if (adminMaxDebt == null) { - logger.error("admin_max_debt not found in the config.") - return false - } - val adminMaxDebtObj = parseTalerAmount2(adminMaxDebt, FracDigits.EIGHT) - if (adminMaxDebtObj == null) { - logger.error("admin_max_debt was invalid in the config.") - return false - } - val internalCurrency = db.configGet("internal_currency") - if (internalCurrency == null) { - logger.error("Bank own currency (internal_currency) not found in the config.") - exitProcess(1) - } - if (adminMaxDebtObj.currency != internalCurrency) { - logger.error("admin_max_debt has an unsupported currency: ${adminMaxDebtObj.currency}.") - return false - } + logger.info("Creating admin bank account") + val adminMaxDebtObj = ctx.defaultAdminDebtLimit val adminBankAccount = BankAccount( hasDebt = false, internalPaytoUri = genIbanPaytoUri(), @@ -516,7 +497,7 @@ fun maybeCreateAdminAccount(db: Database): Boolean { maxDebt = adminMaxDebtObj ) if (db.bankAccountCreate(adminBankAccount) == null) { - logger.error("Failed at creating admin's bank account row.") + logger.error("Failed to creating admin bank account.") return false } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/talerIntegrationHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/talerIntegrationHandlers.kt @@ -28,10 +28,9 @@ import io.ktor.server.routing.* import net.taler.common.errorcodes.TalerErrorCode import tech.libeufin.util.getBaseUrl -fun Routing.talerIntegrationHandlers(db: Database) { +fun Routing.talerIntegrationHandlers(db: Database, ctx: BankApplicationContext) { get("/taler-integration/config") { - val internalCurrency: String = db.configGet("internal_currency") - ?: throw internalServerError("Currency not found") + val internalCurrency: String = ctx.currency call.respond(TalerIntegrationConfigResponse(currency = internalCurrency)) return@get } @@ -42,8 +41,7 @@ fun Routing.talerIntegrationHandlers(db: Database) { val relatedBankAccount = db.bankAccountGetFromOwnerId(op.walletBankAccount) if (relatedBankAccount == null) throw internalServerError("Bank has a withdrawal not related to any bank account.") - val suggestedExchange = db.configGet("suggested_exchange") - ?: throw internalServerError("Bank does not have an exchange to suggest.") + val suggestedExchange = ctx.suggestedWithdrawalExchange val walletCustomer = db.customerGetFromRowId(relatedBankAccount.owningCustomerId) if (walletCustomer == null) throw internalServerError("Could not resort the username that owns this withdrawal") @@ -84,9 +82,6 @@ fun Routing.talerIntegrationHandlers(db: Database) { TalerErrorCode.TALER_EC_BANK_DUPLICATE_RESERVE_PUB_SUBJECT ) val exchangePayto = req.selected_exchange - ?: (db.configGet("suggested_exchange") - ?: throw internalServerError("Suggested exchange not found") - ) db.talerWithdrawalSetDetails( op.withdrawalUuid, exchangePayto, diff --git a/bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt @@ -29,11 +29,9 @@ import io.ktor.server.routing.* import net.taler.common.errorcodes.TalerErrorCode import tech.libeufin.util.getNowUs -fun Routing.talerWireGatewayHandlers(db: Database) { +fun Routing.talerWireGatewayHandlers(db: Database, ctx: BankApplicationContext) { get("/taler-wire-gateway/config") { - val internalCurrency = db.configGet("internal_currency") - ?: throw internalServerError("Could not find bank own currency.") - call.respond(TWGConfigResponse(currency = internalCurrency)) + call.respond(TWGConfigResponse(currency = ctx.currency)) return@get } get("/accounts/{USERNAME}/taler-wire-gateway/history/incoming") { @@ -93,8 +91,7 @@ fun Routing.talerWireGatewayHandlers(db: Database) { ) } // Legitimate request, go on. - val internalCurrency = db.configGet("internal_currency") - ?: throw internalServerError("Bank did not find own internal currency.") + val internalCurrency = ctx.currency if (internalCurrency != req.amount.currency) throw badRequest("Currency mismatch: $internalCurrency vs ${req.amount.currency}") val exchangeBankAccount = db.bankAccountGetFromOwnerId(c.expectRowId()) @@ -128,8 +125,7 @@ fun Routing.talerWireGatewayHandlers(db: Database) { if (!call.getResourceName("USERNAME").canI(c, withAdmin = false)) throw forbidden() val req = call.receive<AddIncomingRequest>() val amount = parseTalerAmount(req.amount) - val internalCurrency = db.configGet("internal_currency") - ?: throw internalServerError("Bank didn't find own currency.") + val internalCurrency = ctx.currency if (amount.currency != internalCurrency) throw badRequest( "Currency mismatch", diff --git a/bank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt @@ -32,7 +32,7 @@ import tech.libeufin.util.getNowUs private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.accountsMgmtHandlers") -fun Routing.tokenHandlers(db: Database) { +fun Routing.tokenHandlers(db: Database, ctx: BankApplicationContext) { delete("/accounts/{USERNAME}/token") { throw internalServerError("Token deletion not implemented.") } @@ -64,17 +64,8 @@ fun Routing.tokenHandlers(db: Database) { val tokenBytes = ByteArray(32).apply { java.util.Random().nextBytes(this) } - val maxDurationTime: Long = db.configGet("token_max_duration").run { - if (this == null) - return@run Long.MAX_VALUE - return@run try { - this.toLong() - } catch (e: Exception) { - logger.error("Could not convert config's token_max_duration to Long") - throw internalServerError(e.message) - } - } - if (req.duration != null && req.duration.d_us.compareTo(maxDurationTime) == 1) + val maxDurationTime: Long = ctx.maxAuthTokenDurationUs + if (req.duration != null && req.duration.d_us > maxDurationTime) throw forbidden( "Token duration bigger than bank's limit", // FIXME: define new EC for this case. @@ -82,7 +73,7 @@ fun Routing.tokenHandlers(db: Database) { ) val tokenDurationUs = req.duration?.d_us ?: TOKEN_DEFAULT_DURATION_US val customerDbRow = customer.dbRowId ?: throw internalServerError( - "Coud not resort customer '${customer.login}' database row ID" + "Could not resort customer '${customer.login}' database row ID" ) val expirationTimestampUs: Long = getNowUs() + tokenDurationUs if (expirationTimestampUs < tokenDurationUs) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt @@ -16,7 +16,7 @@ import kotlin.math.abs private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.transactionHandlers") -fun Routing.transactionsHandlers(db: Database) { +fun Routing.transactionsHandlers(db: Database, ctx: BankApplicationContext) { get("/accounts/{USERNAME}/transactions") { val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() val resourceName = call.expectUriComponent("USERNAME") @@ -70,7 +70,7 @@ fun Routing.transactionsHandlers(db: Database) { TalerErrorCode.TALER_EC_END // FIXME: define this EC. ) val amount = parseTalerAmount(txData.amount) - if (amount.currency != getBankCurrency(db)) + if (amount.currency != ctx.currency) throw badRequest( "Wrong currency: ${amount.currency}", talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH diff --git a/bank/src/main/kotlin/tech/libeufin/bank/types.kt b/bank/src/main/kotlin/tech/libeufin/bank/types.kt @@ -146,17 +146,17 @@ data class Customer( ) /** -* Represents a Taler amount. This type can be used both -* to hold database records and amounts coming from the parser. -* If maybeCurrency is null, then the constructor defaults it -* to be the "internal currency". Internal currency is the one -* with which Libeufin-Bank moves funds within itself, therefore -* not to be mistaken with the cashout currency, which is the one -* that gets credited to Libeufin-Bank users to their cashout_payto_uri. -* -* maybeCurrency is typically null when the TalerAmount object gets -* defined by the Database class. -*/ + * Represents a Taler amount. This type can be used both + * to hold database records and amounts coming from the parser. + * If maybeCurrency is null, then the constructor defaults it + * to be the "internal currency". Internal currency is the one + * with which Libeufin-Bank moves funds within itself, therefore + * not to be mistaken with the cashout currency, which is the one + * that gets credited to Libeufin-Bank users to their cashout_payto_uri. + * + * maybeCurrency is typically null when the TalerAmount object gets + * defined by the Database class. + */ class TalerAmount( val value: Long, val frac: Int, @@ -437,6 +437,7 @@ enum class WithdrawalConfirmationResult { OP_NOT_FOUND, EXCHANGE_NOT_FOUND, BALANCE_INSUFFICIENT, + /** * This state indicates that the withdrawal was already * confirmed BUT Kotlin did not detect it and still invoked @@ -496,7 +497,7 @@ data class BankWithdrawalOperationStatus( @Serializable data class BankWithdrawalOperationPostRequest( val reserve_pub: String, - val selected_exchange: String? = null // Use suggested exchange if that's missing. + val selected_exchange: String, ) /** @@ -540,6 +541,7 @@ data class IncomingHistory( val incoming_transactions: MutableList<IncomingReserveTransaction> = mutableListOf(), val credit_account: String // Receiver's Payto URI. ) + // TWG's incoming payment record. @Serializable data class IncomingReserveTransaction( diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -70,16 +70,6 @@ class AmountTest { } - /* Testing that currency is fetched from the config - and set in the TalerAmount dedicated field. */ - @Test - fun testAutoCurrency() { - val db = initDb() - db.configSet("internal_currency", "KUDOS") - val a = TalerAmount(1L, 0, getBankCurrency(db)) - assert(a.currency == "KUDOS") - } - @Test fun parseTalerAmountTest() { val one = "EUR:1" diff --git a/bank/src/test/kotlin/Common.kt b/bank/src/test/kotlin/Common.kt @@ -17,7 +17,9 @@ * <http://www.gnu.org/licenses/> */ +import tech.libeufin.bank.BankApplicationContext import tech.libeufin.bank.Database +import tech.libeufin.bank.TalerAmount import tech.libeufin.util.execCommand // Init the database and sets the currency to KUDOS. @@ -35,7 +37,22 @@ fun initDb(): Database { ), throwIfFails = true ) - val db = Database("jdbc:postgresql:///libeufincheck") - db.configSet("internal_currency", "KUDOS") - return db + return Database("jdbc:postgresql:///libeufincheck", "KUDOS") +} + +fun getTestContext( + restrictRegistration: Boolean = false, + suggestedExchange: String = "https://exchange.example.com" +): BankApplicationContext { + return BankApplicationContext( + currency = "KUDOS", + restrictRegistration = restrictRegistration, + cashoutCurrency = "EUR", + defaultCustomerDebtLimit = TalerAmount(100, 0, "KUDOS"), + defaultAdminDebtLimit = TalerAmount(10000, 0, "KUDOS"), + registrationBonusEnabled = false, + registrationBonus = null, + suggestedWithdrawalExchange = suggestedExchange, + maxAuthTokenDurationUs = 200 * 1000000, + ) } \ No newline at end of file diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt @@ -79,14 +79,14 @@ class DatabaseTest { @Test fun createAdminTest() { val db = initDb() + val ctx = getTestContext() val noAdminCustomer = db.customerGetFromLogin("admin") assert(noAdminCustomer == null) - db.configSet("admin_max_debt", "KUDOS:2222") - assert(maybeCreateAdminAccount(db)) + assert(maybeCreateAdminAccount(db, ctx)) val yesAdminCustomer = db.customerGetFromLogin("admin") assert(yesAdminCustomer != null) assert(db.bankAccountGetFromOwnerId(yesAdminCustomer!!.expectRowId()) != null) - assert(maybeCreateAdminAccount(db)) + assert(maybeCreateAdminAccount(db, ctx)) } /** @@ -224,14 +224,7 @@ class DatabaseTest { // Trigger conflict. assert(db.customerCreate(customerFoo) == null) } - @Test - fun configTest() { - val db = initDb() - assert(db.configGet("bar") == null) - assert(db.configGet("bar") == null) - db.configSet("foo", "bar") - assert(db.configGet("foo") == "bar") - } + @Test fun bankAccountTest() { val db = initDb() diff --git a/bank/src/test/kotlin/LibeuFinApiTest.kt b/bank/src/test/kotlin/LibeuFinApiTest.kt @@ -42,14 +42,16 @@ class LibeuFinApiTest { @Test fun getConfig() { val db = initDb() + val ctx = getTestContext() testApplication { - application { corebankWebApp(db) } + application { corebankWebApp(db, ctx) } val r = client.get("/config") { expectSuccess = true } println(r.bodyAsText()) } } + /** * Testing GET /transactions. This test checks that the sign * of delta gets honored by the HTTP handler, namely that the @@ -59,14 +61,17 @@ class LibeuFinApiTest { @Test fun testHistory() { val db = initDb() + val ctx = getTestContext() val fooId = db.customerCreate(customerFoo); assert(fooId != null) assert(db.bankAccountCreate(genBankAccount(fooId!!)) != null) val barId = db.customerCreate(customerBar); assert(barId != null) assert(db.bankAccountCreate(genBankAccount(barId!!)) != null) - for (i in 1..10) { db.bankTransactionCreate(genTx("test-$i")) } + for (i in 1..10) { + db.bankTransactionCreate(genTx("test-$i")) + } testApplication { application { - corebankWebApp(db) + corebankWebApp(db, ctx) } val asc = client.get("/accounts/foo/transactions?delta=2") { basicAuth("foo", "pw") @@ -89,6 +94,7 @@ class LibeuFinApiTest { @Test fun postTransactionsTest() { val db = initDb() + val ctx = getTestContext() // foo account val fooId = db.customerCreate(customerFoo); assert(fooId != null) assert(db.bankAccountCreate(genBankAccount(fooId!!)) != null) @@ -98,18 +104,20 @@ class LibeuFinApiTest { // accounts exist, now create one transaction. testApplication { application { - corebankWebApp(db) + corebankWebApp(db, ctx) } client.post("/accounts/foo/transactions") { expectSuccess = true basicAuth("foo", "pw") contentType(ContentType.Application.Json) // expectSuccess = true - setBody("""{ + setBody( + """{ "payto_uri": "payto://iban/SANDBOXX/${barId}-IBAN?message=payout", "amount": "KUDOS:3.3" } - """.trimIndent()) + """.trimIndent() + ) } // Getting the only tx that exists in the DB, hence has ID == 1. val r = client.get("/accounts/foo/transactions/1") { @@ -120,22 +128,26 @@ class LibeuFinApiTest { assert(obj.subject == "payout") } } + // Checking the POST /token handling. @Test fun tokenTest() { val db = initDb() + val ctx = getTestContext() assert(db.customerCreate(customerFoo) != null) testApplication { application { - corebankWebApp(db) + corebankWebApp(db, ctx) } client.post("/accounts/foo/token") { expectSuccess = true contentType(ContentType.Application.Json) basicAuth("foo", "pw") - setBody(""" + setBody( + """ {"scope": "readonly"} - """.trimIndent()) + """.trimIndent() + ) } // foo tries on bar endpoint val r = client.post("/accounts/bar/token") { @@ -145,14 +157,18 @@ class LibeuFinApiTest { assert(r.status == HttpStatusCode.Forbidden) // Make ad-hoc token for foo. val fooTok = ByteArray(32).apply { Random.nextBytes(this) } - assert(db.bearerTokenCreate(BearerToken( - content = fooTok, - bankCustomer = 1L, // only foo exists. - scope = TokenScope.readonly, - creationTime = getNowUs(), - isRefreshable = true, - expirationTime = getNowUs() + (Duration.ofHours(1).toMillis() * 1000) - ))) + assert( + db.bearerTokenCreate( + BearerToken( + content = fooTok, + bankCustomer = 1L, // only foo exists. + scope = TokenScope.readonly, + creationTime = getNowUs(), + isRefreshable = true, + expirationTime = getNowUs() + (Duration.ofHours(1).toMillis() * 1000) + ) + ) + ) // Testing the bearer-token:-scheme. client.post("/accounts/foo/token") { headers.set("Authorization", "Bearer bearer-token:${Base32Crockford.encode(fooTok)}") @@ -172,23 +188,28 @@ class LibeuFinApiTest { fun getAccountTest() { // Artificially insert a customer and bank account in the database. val db = initDb() - val customerRowId = db.customerCreate(Customer( - "foo", - CryptoUtil.hashpw("pw"), - "Foo" - )) - assert(customerRowId != null) - assert(db.bankAccountCreate( - BankAccount( - hasDebt = false, - internalPaytoUri = "payto://iban/SANDBOXX/FOO-IBAN", - maxDebt = TalerAmount(100, 0, "KUDOS"), - owningCustomerId = customerRowId!! + val ctx = getTestContext() + val customerRowId = db.customerCreate( + Customer( + "foo", + CryptoUtil.hashpw("pw"), + "Foo" ) - ) != null) + ) + assert(customerRowId != null) + assert( + db.bankAccountCreate( + BankAccount( + hasDebt = false, + internalPaytoUri = "payto://iban/SANDBOXX/FOO-IBAN", + maxDebt = TalerAmount(100, 0, "KUDOS"), + owningCustomerId = customerRowId!! + ) + ) != null + ) testApplication { application { - corebankWebApp(db) + corebankWebApp(db, ctx) } val r = client.get("/accounts/foo") { expectSuccess = true @@ -197,18 +218,24 @@ class LibeuFinApiTest { val obj: AccountData = Json.decodeFromString(r.bodyAsText()) assert(obj.name == "Foo") // Checking admin can. - val adminRowId = db.customerCreate(Customer( - "admin", - CryptoUtil.hashpw("admin"), - "Admin" - )) + val adminRowId = db.customerCreate( + Customer( + "admin", + CryptoUtil.hashpw("admin"), + "Admin" + ) + ) assert(adminRowId != null) - assert(db.bankAccountCreate(BankAccount( - hasDebt = false, - internalPaytoUri = "payto://iban/SANDBOXX/ADMIN-IBAN", - maxDebt = TalerAmount(100, 0, "KUDOS"), - owningCustomerId = adminRowId!! - )) != null) + assert( + db.bankAccountCreate( + BankAccount( + hasDebt = false, + internalPaytoUri = "payto://iban/SANDBOXX/ADMIN-IBAN", + maxDebt = TalerAmount(100, 0, "KUDOS"), + owningCustomerId = adminRowId!! + ) + ) != null + ) client.get("/accounts/foo") { expectSuccess = true basicAuth("admin", "admin") @@ -220,75 +247,97 @@ class LibeuFinApiTest { assert(shouldNot.status == HttpStatusCode.NotFound) } } + /** - * Testing the account creation, its idempotency and - * the restriction to admin to create accounts. + * Testing the account creation and its idempotency */ @Test fun createAccountTest() { testApplication { val db = initDb() + val ctx = getTestContext() val ibanPayto = genIbanPaytoUri() - // Bank needs those to operate: - db.configSet("max_debt_ordinary_customers", "KUDOS:11") application { - corebankWebApp(db) + corebankWebApp(db, ctx) } var resp = client.post("/accounts") { expectSuccess = false contentType(ContentType.Application.Json) - setBody("""{ + setBody( + """{ "username": "foo", "password": "bar", "name": "Jane", "internal_payto_uri": "$ibanPayto" - }""".trimIndent()) + }""".trimIndent() + ) } assert(resp.status == HttpStatusCode.Created) // Testing idempotency. resp = client.post("/accounts") { expectSuccess = false contentType(ContentType.Application.Json) - setBody("""{ + setBody( + """{ "username": "foo", "password": "bar", "name": "Jane", "internal_payto_uri": "$ibanPayto" - }""".trimIndent()) + }""".trimIndent() + ) } assert(resp.status == HttpStatusCode.Created) // Creating the administrator. - db.customerCreate(Customer( - "admin", - CryptoUtil.hashpw("pass"), - "CFO" - )) - db.configSet("only_admin_registrations", "yes") + db.customerCreate( + Customer( + "admin", + CryptoUtil.hashpw("pass"), + "CFO" + ) + ) + } + } + + /** + * Test admin-only account creation + */ + @Test + fun createAccountRestrictedTest() { + testApplication { + val db = initDb() + // For this test, we restrict registrations + val ctx = getTestContext(restrictRegistration = true) + + application { + corebankWebApp(db, ctx) + } + // Ordinary user tries, should fail. - resp = client.post("/accounts") { + var resp = client.post("/accounts") { expectSuccess = false basicAuth("foo", "bar") contentType(ContentType.Application.Json) - setBody("""{ + setBody( + """{ "username": "baz", "password": "xyz", "name": "Mallory" - }""".trimIndent()) + }""".trimIndent() + ) } assert(resp.status == HttpStatusCode.Unauthorized) - // admin tries (also giving bonus), should succeed - db.configSet("admin_max_debt", "KUDOS:2222") - db.configSet("registration_bonus", "KUDOS:32") - assert(maybeCreateAdminAccount(db)) // customer exists, this makes only the bank account. + assert(maybeCreateAdminAccount(db, ctx)) // customer exists, this makes only the bank account. resp = client.post("/accounts") { expectSuccess = false basicAuth("admin", "pass") contentType(ContentType.Application.Json) - setBody("""{ + setBody( + """{ "username": "baz", "password": "xyz", "name": "Mallory" - }""".trimIndent()) + }""".trimIndent() + ) } assert(resp.status == HttpStatusCode.Created) } diff --git a/bank/src/test/kotlin/TalerApiTest.kt b/bank/src/test/kotlin/TalerApiTest.kt @@ -47,6 +47,7 @@ class TalerApiTest { @Test fun transfer() { val db = initDb() + val ctx = getTestContext() // Creating the exchange and merchant accounts first. assert(db.customerCreate(customerFoo) != null) assert(db.bankAccountCreate(bankAccountFoo) != null) @@ -60,7 +61,7 @@ class TalerApiTest { // Do POST /transfer. testApplication { application { - corebankWebApp(db) + corebankWebApp(db, ctx) } val req = """ { @@ -125,6 +126,7 @@ class TalerApiTest { @Test fun historyIncoming() { val db = initDb() + val ctx = getTestContext() assert(db.customerCreate(customerFoo) != null) assert(db.bankAccountCreate(bankAccountFoo) != null) assert(db.customerCreate(customerBar) != null) @@ -145,7 +147,7 @@ class TalerApiTest { // Bar expects two entries in the incoming history testApplication { application { - corebankWebApp(db) + corebankWebApp(db, ctx) } val resp = client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=5") { basicAuth("bar", "secret") @@ -160,6 +162,7 @@ class TalerApiTest { @Test fun addIncoming() { val db = initDb() + val ctx = getTestContext() assert(db.customerCreate(customerFoo) != null) assert(db.bankAccountCreate(bankAccountFoo) != null) assert(db.customerCreate(customerBar) != null) @@ -171,7 +174,7 @@ class TalerApiTest { )) testApplication { application { - corebankWebApp(db) + corebankWebApp(db, ctx) } client.post("/accounts/foo/taler-wire-gateway/admin/add-incoming") { expectSuccess = true @@ -190,13 +193,10 @@ class TalerApiTest { @Test fun intSelect() { val db = initDb() + val ctx = getTestContext(suggestedExchange = "payto://suggested-exchange") val uuid = UUID.randomUUID() assert(db.customerCreate(customerFoo) != null) assert(db.bankAccountCreate(bankAccountFoo) != null) - db.configSet( - "suggested_exchange", - "payto://suggested-exchange" - ) // insert new. assert(db.talerWithdrawalCreate( opUUID = uuid, @@ -205,7 +205,7 @@ class TalerApiTest { )) testApplication { application { - corebankWebApp(db) + corebankWebApp(db, ctx) } val r = client.post("/taler-integration/withdrawal-operation/${uuid}") { expectSuccess = true @@ -225,10 +225,7 @@ class TalerApiTest { val uuid = UUID.randomUUID() assert(db.customerCreate(customerFoo) != null) assert(db.bankAccountCreate(bankAccountFoo) != null) - db.configSet( - "suggested_exchange", - "payto://suggested-exchange" - ) + val ctx = getTestContext(suggestedExchange = "payto://suggested-exchange") // insert new. assert(db.talerWithdrawalCreate( opUUID = uuid, @@ -237,7 +234,7 @@ class TalerApiTest { )) testApplication { application { - corebankWebApp(db) + corebankWebApp(db, ctx) } val r = client.get("/taler-integration/withdrawal-operation/${uuid}") { expectSuccess = true @@ -250,6 +247,7 @@ class TalerApiTest { fun withdrawalAbort() { val db = initDb() val uuid = UUID.randomUUID() + val ctx = getTestContext() assert(db.customerCreate(customerFoo) != null) assert(db.bankAccountCreate(bankAccountFoo) != null) // insert new. @@ -262,7 +260,7 @@ class TalerApiTest { assert(op?.aborted == false) testApplication { application { - corebankWebApp(db) + corebankWebApp(db, ctx) } client.post("/accounts/foo/withdrawals/${uuid}/abort") { expectSuccess = true @@ -276,11 +274,12 @@ class TalerApiTest { @Test fun withdrawalCreation() { val db = initDb() + val ctx = getTestContext() assert(db.customerCreate(customerFoo) != null) assert(db.bankAccountCreate(bankAccountFoo) != null) testApplication { application { - corebankWebApp(db) + corebankWebApp(db, ctx) } // Creating the withdrawal as if the SPA did it. val r = client.post("/accounts/foo/withdrawals") { @@ -303,6 +302,7 @@ class TalerApiTest { @Test fun withdrawalConfirmation() { val db = initDb() + val ctx = getTestContext() // Creating Foo as the wallet owner and Bar as the exchange. assert(db.customerCreate(customerFoo) != null) assert(db.bankAccountCreate(bankAccountFoo) != null) @@ -326,7 +326,7 @@ class TalerApiTest { // Starting the bank and POSTing as Foo to /confirm the operation. testApplication { application { - corebankWebApp(db) + corebankWebApp(db, ctx) } client.post("/accounts/foo/withdrawals/${uuid}/confirm") { expectSuccess = true // Sufficient to assert on success. diff --git a/database-versioning/libeufin-bank-0001.sql b/database-versioning/libeufin-bank-0001.sql @@ -49,15 +49,6 @@ CREATE TYPE subscriber_state_enum -- FIXME: comments on types (see exchange for example)! --- start of: bank config tables. FIXME: eventually replaced by the INI file. - -CREATE TABLE IF NOT EXISTS configuration - (config_key TEXT PRIMARY KEY - ,config_value TEXT - ); - --- end of: bank config tables - -- start of: bank accounts CREATE TABLE IF NOT EXISTS customers diff --git a/util/src/main/kotlin/TalerConfig.kt b/util/src/main/kotlin/TalerConfig.kt @@ -114,10 +114,10 @@ class TalerConfig { /** * Look up a string value from the configuration. * - * Return an empty Optional if the value was not found in the configuration. + * Return null if the value was not found in the configuration. */ - fun lookupValueString(section: String, option: String): Optional<String> { - return Optional.ofNullable(lookupEntry(section, option)?.value) + fun lookupValueString(section: String, option: String): String? { + return lookupEntry(section, option)?.value } fun requireValueString(section: String, option: String): String { @@ -128,6 +128,21 @@ class TalerConfig { return entry.value } + fun lookupValueBooleanDefault(section: String, option: String, default: Boolean): Boolean { + val entry = lookupEntry(section, option) + if (entry == null) { + return default + } + val v = entry.value.lowercase() + if (v == "yes") { + return true; + } + if (v == "false") { + return false; + } + throw TalerConfigError("expected yes/no in configuration section $section option $option but got $v") + } + /** * Create a string representation of the loaded configuration. */ diff --git a/util/src/test/kotlin/TalerConfigTest.kt b/util/src/test/kotlin/TalerConfigTest.kt @@ -38,7 +38,7 @@ class TalerConfigTest { println(conf.stringify()) - assertEquals("baz", conf.lookupValueString("foo", "bar").orElseThrow()) + assertEquals("baz", conf.lookupValueString("foo", "bar")) println(TalerConfig.getTalerInstallPath()) }