libeufin

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

commit f52e5964270a2509323c78803317dffe9dde3bc4
parent 1d10fa6fded425cc2d82c30c49d5bf15bac54ed9
Author: Florian Dold <florian@dold.me>
Date:   Fri, 22 Sep 2023 11:29:35 +0200

libeufin-bank: implement main()

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mbank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt | 4++--
Mbank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt | 8++++----
Mbank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt | 6+++---
Mbank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt | 2+-
Mbank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt | 6+++---
Mbank/src/test/kotlin/LibeuFinApiTest.kt | 20+++++++++++++++-----
Mbank/src/test/kotlin/TalerApiTest.kt | 33++++++++++++++++++++++++---------
Mutil/src/main/kotlin/TalerConfig.kt | 8++++++++
9 files changed, 126 insertions(+), 38 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -20,12 +20,24 @@ package tech.libeufin.bank +import TalerConfig +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.context +import com.github.ajalt.clikt.core.subcommands +import com.github.ajalt.clikt.output.CliktHelpFormatter +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.versionOption +import com.github.ajalt.clikt.parameters.types.int import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.plugins.* import io.ktor.server.plugins.requestvalidation.* import io.ktor.server.plugins.contentnegotiation.* import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* import io.ktor.server.plugins.callloging.* import kotlinx.serialization.* import io.ktor.server.plugins.cors.routing.* @@ -33,6 +45,9 @@ import io.ktor.server.plugins.statuspages.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder @@ -44,10 +59,10 @@ import org.slf4j.LoggerFactory import org.slf4j.event.Level import tech.libeufin.util.* import java.time.Duration +import kotlin.system.exitProcess // GLOBALS private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.Main") -private val db = Database(System.getProperty("BANK_DB_CONNECTION_STRING")) const val GENERIC_UNDEFINED = -1 // Filler for ECs that don't exist yet. val TOKEN_DEFAULT_DURATION_US = Duration.ofDays(1L).seconds * 1000000 @@ -62,6 +77,7 @@ object RelativeTimeSerializer : KSerializer<RelativeTime> { override fun serialize(encoder: Encoder, value: RelativeTime) { throw internalServerError("Encoding of RelativeTime not implemented.") // API doesn't require this. } + override fun deserialize(decoder: Decoder): RelativeTime { val jsonInput = decoder as? JsonDecoder ?: throw internalServerError("RelativeTime had no JsonDecoder") val json = try { @@ -77,7 +93,8 @@ object RelativeTimeSerializer : KSerializer<RelativeTime> { if (maybeDUs.content != "forever") throw badRequest("Only 'forever' allowed for d_us as string, but '${maybeDUs.content}' was found") return RelativeTime(d_us = Long.MAX_VALUE) } - val dUs: Long = maybeDUs.longOrNull ?: throw badRequest("Could not convert d_us: '${maybeDUs.content}' to a number") + val dUs: Long = + maybeDUs.longOrNull ?: throw badRequest("Could not convert d_us: '${maybeDUs.content}' to a number") return RelativeTime(d_us = dUs) } @@ -91,9 +108,11 @@ object TalerAmountSerializer : KSerializer<TalerAmount> { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TalerAmount", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: TalerAmount) { throw internalServerError("Encoding of TalerAmount not implemented.") // API doesn't require this. } + override fun deserialize(decoder: Decoder): TalerAmount { val maybeAmount = try { decoder.decodeString() @@ -116,7 +135,7 @@ object TalerAmountSerializer : KSerializer<TalerAmount> { * * Returns the authenticated customer, or null if they failed. */ -fun ApplicationCall.myAuth(requiredScope: TokenScope): Customer? { +fun ApplicationCall.myAuth(db: Database, requiredScope: TokenScope): Customer? { // Extracting the Authorization header. val header = getAuthorizationRawHeader(this.request) ?: throw badRequest( "Authorization header not found.", @@ -139,7 +158,11 @@ fun ApplicationCall.myAuth(requiredScope: TokenScope): Customer? { } } -val webApp: Application.() -> Unit = { + +/** + * Set up web server handlers for the Taler corebank API. + */ +fun Application.corebankWebApp(db: Database) { install(CallLogging) { this.level = Level.DEBUG this.logger = tech.libeufin.bank.logger @@ -181,7 +204,7 @@ val webApp: Application.() -> Unit = { * (Ktor native) type doesn't easily map to the Taler error * format. */ - exception<BadRequestException> {call, cause -> + exception<BadRequestException> { call, cause -> /** * NOTE: extracting the root cause helps with JSON error messages, * because they mention the particular way they are invalid, but OTOH @@ -198,11 +221,13 @@ val webApp: Application.() -> Unit = { val errorMessage: String? = rootCause?.message ?: cause.message logger.error(errorMessage) // Telling apart invalid JSON vs missing parameter vs invalid parameter. - val talerErrorCode = when(cause) { + val talerErrorCode = when (cause) { is MissingRequestParameterException -> TalerErrorCode.TALER_EC_GENERIC_PARAMETER_MISSING + is ParameterConversionException -> TalerErrorCode.TALER_EC_GENERIC_PARAMETER_MALFORMED + else -> TalerErrorCode.TALER_EC_GENERIC_JSON_INVALID } call.respond( @@ -210,7 +235,8 @@ val webApp: Application.() -> Unit = { message = TalerError( code = talerErrorCode.code, hint = errorMessage - )) + ) + ) } /** * This branch triggers when a bank handler throws it, and namely @@ -218,7 +244,7 @@ val webApp: Application.() -> Unit = { * should be preferred to catch errors, as it allows to include the * Taler specific error detail. */ - exception<LibeufinBankException> {call, cause -> + exception<LibeufinBankException> { call, cause -> logger.error(cause.talerError.hint) call.respond( status = cause.httpStatus, @@ -226,7 +252,7 @@ val webApp: Application.() -> Unit = { ) } // Catch-all branch to mean that the bank wasn't able to manage one error. - exception<Exception> {call, cause -> + exception<Exception> { call, cause -> cause.printStackTrace() logger.error(cause.message) call.respond( @@ -250,4 +276,34 @@ val webApp: Application.() -> Unit = { this.talerIntegrationHandlers(db) this.talerWireGatewayHandlers(db) } -} -\ No newline at end of file +} + +class LibeufinBankCommand : CliktCommand() { + init { + versionOption(getVersion()) + } + + override fun run() = Unit +} + +class ServeBank : CliktCommand("Run libeufin-bank HTTP server", name = "serve") { + init { + context { + helpFormatter = CliktHelpFormatter(showDefaultValues = true) + } + } + + override fun run() { + val config = TalerConfig.load() + val dbConnStr = config.requireValueString("libeufin-bank-db-postgres", "config") + logger.info("using database '$dbConnStr'") + val db = Database(dbConnStr) + embeddedServer(Netty, port = 8080) { + corebankWebApp(db) + }.start(wait = true) + } +} + +fun main(args: Array<String>) { + LibeufinBankCommand().subcommands(ServeBank()).main(args) +} diff --git a/bank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/accountsMgmtHandlers.kt @@ -23,7 +23,7 @@ fun Routing.accountsMgmtHandlers(db: Database) { // check if only admin. val maybeOnlyAdmin = db.configGet("only_admin_registrations") if (maybeOnlyAdmin?.lowercase() == "yes") { - val customer: Customer? = call.myAuth(TokenScope.readwrite) + val customer: Customer? = call.myAuth(db, TokenScope.readwrite) if (customer == null || customer.login != "admin") throw LibeufinBankException( httpStatus = HttpStatusCode.Unauthorized, @@ -110,7 +110,7 @@ fun Routing.accountsMgmtHandlers(db: Database) { return@post } get("/accounts/{USERNAME}") { - val c = call.myAuth(TokenScope.readonly) ?: throw unauthorized("Login failed") + val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized("Login failed") val resourceName = call.maybeUriComponent("USERNAME") ?: throw badRequest( hint = "No username found in the URI", talerErrorCode = TalerErrorCode.TALER_EC_GENERIC_PARAMETER_MISSING diff --git a/bank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/talerWebHandlers.kt @@ -38,7 +38,7 @@ import java.util.* fun Routing.talerWebHandlers(db: Database) { post("/accounts/{USERNAME}/withdrawals") { - val c = call.myAuth(TokenScope.readwrite) ?: throw unauthorized() + val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() // Admin not allowed to withdraw in the name of customers: val accountName = call.expectUriComponent("USERNAME") if (c.login != accountName) @@ -79,7 +79,7 @@ fun Routing.talerWebHandlers(db: Database) { return@post } get("/accounts/{USERNAME}/withdrawals/{withdrawal_id}") { - val c = call.myAuth(TokenScope.readonly) ?: throw unauthorized() + val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() val accountName = call.expectUriComponent("USERNAME") // Admin allowed to see the details if (c.login != accountName && c.login != "admin") throw forbidden() @@ -96,7 +96,7 @@ fun Routing.talerWebHandlers(db: Database) { return@get } post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/abort") { - val c = call.myAuth(TokenScope.readonly) ?: throw unauthorized() + val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() // Admin allowed to abort. if (!call.getResourceName("USERNAME").canI(c)) throw forbidden() val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) @@ -114,7 +114,7 @@ fun Routing.talerWebHandlers(db: Database) { return@post } post("/accounts/{USERNAME}/withdrawals/{withdrawal_id}/confirm") { - val c = call.myAuth(TokenScope.readwrite) ?: throw unauthorized() + val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() // No admin allowed. if(!call.getResourceName("USERNAME").canI(c, withAdmin = false)) throw forbidden() val op = getWithdrawal(db, call.expectUriComponent("withdrawal_id")) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/talerWireGatewayHandlers.kt @@ -37,7 +37,7 @@ fun Routing.talerWireGatewayHandlers(db: Database) { return@get } get("/accounts/{USERNAME}/taler-wire-gateway/history/incoming") { - val c = call.myAuth(TokenScope.readonly) ?: throw unauthorized() + val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() if (!call.getResourceName("USERNAME").canI(c, withAdmin = true)) throw forbidden() val params = getHistoryParams(call.request) val bankAccount = db.bankAccountGetFromOwnerId(c.expectRowId()) @@ -69,7 +69,7 @@ fun Routing.talerWireGatewayHandlers(db: Database) { return@get } post("/accounts/{USERNAME}/taler-wire-gateway/transfer") { - val c = call.myAuth(TokenScope.readwrite) ?: throw unauthorized() + val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() if (!call.getResourceName("USERNAME").canI(c, withAdmin = false)) throw forbidden() val req = call.receive<TransferRequest>() // Checking for idempotency. @@ -124,7 +124,7 @@ fun Routing.talerWireGatewayHandlers(db: Database) { return@post } post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming") { - val c = call.myAuth(TokenScope.readwrite) ?: throw unauthorized() + val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() if (!call.getResourceName("USERNAME").canI(c, withAdmin = false)) throw forbidden() val req = call.receive<AddIncomingRequest>() val amount = parseTalerAmount(req.amount) diff --git a/bank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/tokenHandlers.kt @@ -37,7 +37,7 @@ fun Routing.tokenHandlers(db: Database) { throw internalServerError("Token deletion not implemented.") } post("/accounts/{USERNAME}/token") { - val customer = call.myAuth(TokenScope.refreshable) ?: throw unauthorized("Authentication failed") + val customer = call.myAuth(db, TokenScope.refreshable) ?: throw unauthorized("Authentication failed") val endpointOwner = call.maybeUriComponent("USERNAME") if (customer.login != endpointOwner) throw forbidden( diff --git a/bank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt b/bank/src/main/kotlin/tech/libeufin/bank/transactionsHandlers.kt @@ -18,7 +18,7 @@ private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.transac fun Routing.transactionsHandlers(db: Database) { get("/accounts/{USERNAME}/transactions") { - val c = call.myAuth(TokenScope.readonly) ?: throw unauthorized() + val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() val resourceName = call.expectUriComponent("USERNAME") if (c.login != resourceName && c.login != "admin") throw forbidden() // Collecting params. @@ -51,7 +51,7 @@ fun Routing.transactionsHandlers(db: Database) { } // Creates a bank transaction. post("/accounts/{USERNAME}/transactions") { - val c = call.myAuth(TokenScope.readwrite) ?: throw unauthorized() + val c = call.myAuth(db, TokenScope.readwrite) ?: throw unauthorized() val resourceName = call.expectUriComponent("USERNAME") // admin has no rights here. if ((c.login != resourceName) && (call.getAuthToken() == null)) @@ -98,7 +98,7 @@ fun Routing.transactionsHandlers(db: Database) { return@post } get("/accounts/{USERNAME}/transactions/{T_ID}") { - val c = call.myAuth(TokenScope.readonly) ?: throw unauthorized() + val c = call.myAuth(db, TokenScope.readonly) ?: throw unauthorized() val accountOwner = call.expectUriComponent("USERNAME") // auth ok, check rights. if (c.login != "admin" && c.login != accountOwner) diff --git a/bank/src/test/kotlin/LibeuFinApiTest.kt b/bank/src/test/kotlin/LibeuFinApiTest.kt @@ -54,7 +54,9 @@ class LibeuFinApiTest { assert(db.bankAccountCreate(genBankAccount(barId!!))) for (i in 1..10) { db.bankTransactionCreate(genTx("test-$i")) } testApplication { - application(webApp) + application { + corebankWebApp(db) + } val asc = client.get("/accounts/foo/transactions?delta=2") { basicAuth("foo", "pw") expectSuccess = true @@ -84,7 +86,9 @@ class LibeuFinApiTest { assert(db.bankAccountCreate(genBankAccount(barId!!))) // accounts exist, now create one transaction. testApplication { - application(webApp) + application { + corebankWebApp(db) + } client.post("/accounts/foo/transactions") { expectSuccess = true basicAuth("foo", "pw") @@ -111,7 +115,9 @@ class LibeuFinApiTest { val db = initDb() assert(db.customerCreate(customerFoo) != null) testApplication { - application(webApp) + application { + corebankWebApp(db) + } client.post("/accounts/foo/token") { expectSuccess = true contentType(ContentType.Application.Json) @@ -170,7 +176,9 @@ class LibeuFinApiTest { ) )) testApplication { - application(webApp) + application { + corebankWebApp(db) + } val r = client.get("/accounts/foo") { expectSuccess = true basicAuth("foo", "pw") @@ -212,7 +220,9 @@ class LibeuFinApiTest { val ibanPayto = genIbanPaytoUri() // Bank needs those to operate: db.configSet("max_debt_ordinary_customers", "KUDOS:11") - application(webApp) + application { + corebankWebApp(db) + } var resp = client.post("/accounts") { expectSuccess = false contentType(ContentType.Application.Json) diff --git a/bank/src/test/kotlin/TalerApiTest.kt b/bank/src/test/kotlin/TalerApiTest.kt @@ -4,7 +4,6 @@ import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.testing.* import kotlinx.serialization.json.Json -import org.junit.Ignore import org.junit.Test import tech.libeufin.bank.* import tech.libeufin.util.CryptoUtil @@ -60,7 +59,9 @@ class TalerApiTest { )) // Do POST /transfer. testApplication { - application(webApp) + application { + corebankWebApp(db) + } val req = """ { "request_uid": "entropic 0", @@ -143,7 +144,9 @@ class TalerApiTest { ) // Bar expects two entries in the incoming history testApplication { - application(webApp) + application { + corebankWebApp(db) + } val resp = client.get("/accounts/bar/taler-wire-gateway/history/incoming?delta=5") { basicAuth("bar", "secret") expectSuccess = true @@ -167,7 +170,9 @@ class TalerApiTest { TalerAmount(1000, 0, "KUDOS") )) testApplication { - application(webApp) + application { + corebankWebApp(db) + } client.post("/accounts/foo/taler-wire-gateway/admin/add-incoming") { expectSuccess = true contentType(ContentType.Application.Json) @@ -199,7 +204,9 @@ class TalerApiTest { amount = TalerAmount(1, 0, "KUDOS") )) testApplication { - application(webApp) + application { + corebankWebApp(db) + } val r = client.post("/taler-integration/withdrawal-operation/${uuid}") { expectSuccess = true contentType(ContentType.Application.Json) @@ -229,7 +236,9 @@ class TalerApiTest { amount = TalerAmount(1, 0, "KUDOS") )) testApplication { - application(webApp) + application { + corebankWebApp(db) + } val r = client.get("/taler-integration/withdrawal-operation/${uuid}") { expectSuccess = true } @@ -252,7 +261,9 @@ class TalerApiTest { val op = db.talerWithdrawalGet(uuid) assert(op?.aborted == false) testApplication { - application(webApp) + application { + corebankWebApp(db) + } client.post("/accounts/foo/withdrawals/${uuid}/abort") { expectSuccess = true basicAuth("foo", "pw") @@ -268,7 +279,9 @@ class TalerApiTest { assert(db.customerCreate(customerFoo) != null) assert(db.bankAccountCreate(bankAccountFoo)) testApplication { - application(webApp) + application { + corebankWebApp(db) + } // Creating the withdrawal as if the SPA did it. val r = client.post("/accounts/foo/withdrawals") { basicAuth("foo", "pw") @@ -312,7 +325,9 @@ class TalerApiTest { // Starting the bank and POSTing as Foo to /confirm the operation. testApplication { - application(webApp) + application { + corebankWebApp(db) + } client.post("/accounts/foo/withdrawals/${uuid}/confirm") { expectSuccess = true // Sufficient to assert on success. basicAuth("foo", "pw") diff --git a/util/src/main/kotlin/TalerConfig.kt b/util/src/main/kotlin/TalerConfig.kt @@ -120,6 +120,14 @@ class TalerConfig { return Optional.ofNullable(lookupEntry(section, option)?.value) } + fun requireValueString(section: String, option: String): String { + val entry = lookupEntry(section, option) + if (entry == null) { + throw TalerConfigError("expected string in configuration section $section option $option") + } + return entry.value + } + /** * Create a string representation of the loaded configuration. */