libeufin

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

commit 3a107bb8b15e04c50f9b5a9f41ab64ec6dc24d48
parent c98095703f95682e87ffe8a57a5434e1e97c4b40
Author: Florian Dold <dold@taler.net>
Date:   Mon, 24 Aug 2026 01:19:40 +0200

libeufin-bank: various fixes and tests for token, history, and transfer APIs

Diffstat:
Mdatabase-versioning/libeufin-bank-procedures.sql | 16+++++++++++-----
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 2+-
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt | 59+++++++++++++++++++++++++++++++++++++++++++----------------
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt | 30++++++++++++++++++++++++++++++
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt | 5+++--
Mlibeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mlibeufin-bank/src/test/kotlin/CoreBankApiTest.kt | 132++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mlibeufin-bank/src/test/kotlin/DatabaseTest.kt | 11+++++------
Mlibeufin-bank/src/test/kotlin/PreparedTransferApiTest.kt | 5+++--
9 files changed, 288 insertions(+), 42 deletions(-)

diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -1507,8 +1507,12 @@ SELECT amount_local.val, amount_local.frac, out_missing_amount, out_amount_differs - FROM taler_withdrawal_operations - WHERE withdrawal_uuid=in_withdrawal_uuid; + FROM taler_withdrawal_operations AS op + WHERE op.withdrawal_uuid=in_withdrawal_uuid + -- Prepared-transfer withdrawals are intentionally unbound until the + -- first confirmation; ordinary withdrawals are bound at creation. + AND (op.wallet_bank_account IS NULL + OR op.wallet_bank_account=wallet_bank_account_local); out_no_op=NOT FOUND; IF out_no_op OR already_confirmed OR out_aborted OR out_not_selected OR out_missing_amount OR out_amount_differs OR out_tan_required THEN RETURN; @@ -1539,7 +1543,9 @@ END IF; -- Confirm operation and update amount UPDATE taler_withdrawal_operations - SET amount=amount_local, confirmation_done=true + SET amount=amount_local, + wallet_bank_account=COALESCE(wallet_bank_account, wallet_bank_account_local), + confirmation_done=true WHERE withdrawal_uuid=in_withdrawal_uuid; -- Notify status change @@ -1695,6 +1701,7 @@ SELECT bank_account_id -- Check for idempotence and conflict SELECT (amount_debit != in_amount_debit + OR amount_credit != in_amount_credit OR subject != in_subject OR bank_account != account_id) , cashout_id @@ -2452,4 +2459,4 @@ out_found = FOUND; -- TODO abort withdrawal END $$; -COMMIT; -\ No newline at end of file +COMMIT; diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -470,7 +470,7 @@ data class TokenInfo( @Description("Permission scope of the token") val scope: TokenScope, @Description("Whether the token can be refreshed") - val isRefreshable: Boolean, + val refreshable: Boolean, @Description("Human-readable token description") val description: String? = null, @Description("Timestamp of last token usage") diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt @@ -118,6 +118,7 @@ fun Routing.coreBankApi(db: Database, cfg: BankConfig) { private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) { val TOKEN_DEFAULT_DURATION: Duration = Duration.ofDays(1L) + val TOKEN_REFRESH_OVERLAP: Duration = Duration.ofMinutes(5L) auth(db, cfg.pwCrypto, TokenLogicalScope.refreshable, cfg.basicAuthCompat, allowPw = true) { post("/accounts/{USERNAME}/token", { operationId = "createToken" @@ -161,16 +162,35 @@ private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) { throw badRequest("Bad token duration: ${e.message}") } } - when (db.token.create( - username = call.pathUsername, - content = token.raw, - creationTime = creationTime, - expirationTime = expirationTimestamp, - scope = req.scope, - isRefreshable = req.refreshable, - description = req.description, - is2fa = existingToken != null || challenge != null - )) { + val creationResult = if (existingToken == null) { + db.token.create( + username = call.pathUsername, + content = token.raw, + creationTime = creationTime, + expirationTime = expirationTimestamp, + scope = req.scope, + isRefreshable = req.refreshable, + description = req.description, + is2fa = challenge != null + ) + } else { + val refreshed = db.token.refresh( + username = call.pathUsername, + source = existingToken, + content = token.raw, + creationTime = creationTime, + expirationTime = expirationTimestamp, + sourceExpirationCap = creationTime.plus(TOKEN_REFRESH_OVERLAP), + scope = req.scope, + isRefreshable = req.refreshable, + description = req.description + ) + if (!refreshed) throw internalServerError( + "Token used to authenticate refresh disappeared" + ) + TokenCreationResult.Success + } + when (creationResult) { TokenCreationResult.TanRequired -> call.respondMfa(db, Operation.create_token) TokenCreationResult.Success -> call.respond( TokenSuccessResponse( @@ -198,7 +218,7 @@ private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) { } }) { val id = call.longPath("TOKEN_ID") - if (db.token.deleteById(id)) { + if (db.token.deleteById(id, call.pathUsername)) { call.respond(HttpStatusCode.NoContent) } else { throw notFound( @@ -686,12 +706,12 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) { } private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) { - auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { + optAuth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { get("/accounts/{USERNAME}/transactions", { operationId = "getTransactions" description = "Get transaction history for an account" tags = listOf("Core Bank - Transactions") - protected = true + protected = false securitySchemeNames("bearerAuth", "basicAuth") request { pathParameter<String>("USERNAME") { description = "Account username" } @@ -714,7 +734,13 @@ private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) { } }) { val params = HistoryParams.extract(call.request.queryParameters) - val bankAccount = call.bankInfo(db) + call.response.header(HttpHeaders.CacheControl, "no-store") + val bankAccount = if (call.isAuthenticated) { + call.bankInfo(db) + } else { + db.account.publicBankInfo(call.pathUsername) + ?: throw unknownAccount(call.pathUsername) + } val history: List<BankAccountTransactionInfo> = db.transaction.pollHistory(params, bankAccount.bankAccountId) @@ -724,6 +750,8 @@ private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) { call.respond(BankAccountTransactionsResponse(history)) } } + } + auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) { get("/accounts/{USERNAME}/transactions/{T_ID}", { operationId = "getTransaction" description = "Get a specific transaction by ID" @@ -1420,4 +1448,4 @@ private fun Routing.coreBankConversionApi(db: Database, cfg: BankConfig) = condi } } } -} -\ No newline at end of file +} diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt @@ -538,6 +538,36 @@ class AccountDAO(private val db: Database) { } } + /** Get bank info only when [username] identifies a public account. */ + suspend fun publicBankInfo(username: String): BankInfo? = db.serializable( + """ + SELECT + bank_account_id, + internal_payto, + name, + is_taler_exchange, + tan_channels, + email, + phone + FROM bank_accounts + JOIN customers ON customer_id=owning_customer_id + WHERE username=? AND deleted_at IS NULL AND is_public=true + """ + ) { + bind(username) + oneOrNull { + BankInfo( + username = username, + payto = it.getBankPayto("internal_payto", "name", db.ctx), + bankAccountId = it.getLong("bank_account_id"), + isTalerExchange = it.getBoolean("is_taler_exchange"), + channels = it.getEnumSet<TanChannel>("tan_channels"), + phone = it.getString("phone"), + email = it.getString("email") + ) + } + } + /** Check bank info of account [payto] */ suspend fun checkInfo(payto: IbanPayto): AccountInfo? = db.serializable( """ diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt @@ -120,6 +120,7 @@ class TanDAO(private val db: Database) { ,code ,tan_channel ,tan_info + ,retry_counter -- If this is the first time we submit this challenge check there is not too many active challenges ,(retransmission_date = 0 AND ( SELECT count(*) >= ? @@ -139,6 +140,7 @@ class TanDAO(private val db: Database) { oneOrNull { when { it.getBoolean("solved") -> TanSendResult.Solved + it.getInt("retry_counter") <= 0 -> TanSendResult.Expired it.getBoolean("too_many") -> TanSendResult.TooMany else -> { val retransmission = it.getTalerTimestamp("retransmission_date") @@ -244,4 +246,4 @@ class TanDAO(private val db: Database) { ) } } -} -\ No newline at end of file +} diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt @@ -66,6 +66,57 @@ class TokenDAO(private val db: Database) { } } } + + /** + * Atomically create a replacement for [source] and shorten the source + * token's lifetime to [sourceExpirationCap]. LEAST makes the overlap + * deadline non-sliding when the same source is refreshed more than once. + */ + suspend fun refresh( + username: String, + source: ByteArray, + content: ByteArray, + creationTime: Instant, + expirationTime: Instant, + sourceExpirationCap: Instant, + scope: TokenScope, + isRefreshable: Boolean, + description: String? + ): Boolean = db.serializable( + """ + WITH source_token AS ( + UPDATE bearer_tokens + SET expiration_time=LEAST(expiration_time, ?) + WHERE content=? + AND bank_customer=( + SELECT customer_id FROM customers + WHERE username=? AND deleted_at IS NULL + ) + RETURNING bank_customer + ), replacement AS ( + INSERT INTO bearer_tokens ( + content, creation_time, expiration_time, scope, bank_customer, + is_refreshable, description, last_access + ) + SELECT ?, ?, ?, ?::token_scope_enum, bank_customer, ?, ?, ? + FROM source_token + RETURNING bearer_token_id + ) + SELECT EXISTS(SELECT FROM replacement) + """ + ) { + bind(sourceExpirationCap) + bind(source) + bind(username) + bind(content) + bind(creationTime) + bind(expirationTime) + bind(scope) + bind(isRefreshable) + bind(description) + bind(creationTime) + one { it.getBoolean(1) } + } /** Get info for [token] */ suspend fun access(token: ByteArray, accessTime: Instant): BearerToken? = db.serializable( @@ -147,11 +198,19 @@ class TokenDAO(private val db: Database) { executeUpdate() } - /** Delete token [id] */ - suspend fun deleteById(id: Long) = db.serializable( - "DELETE FROM bearer_tokens WHERE bearer_token_id = ?" + /** Delete token [id] owned by the account identified by [username]. */ + suspend fun deleteById(id: Long, username: String) = db.serializable( + """ + DELETE FROM bearer_tokens + WHERE bearer_token_id = ? + AND bank_customer=( + SELECT customer_id FROM customers + WHERE username=? AND deleted_at IS NULL + ) + """ ) { bind(id) + bind(username) executeUpdateCheck() } @@ -184,11 +243,11 @@ class TokenDAO(private val db: Database) { creation_time = it.getTalerTimestamp("creation_time"), expiration = it.getTalerTimestamp("expiration_time"), scope = it.getEnum("scope"), - isRefreshable = it.getBoolean("is_refreshable"), + refreshable = it.getBoolean("is_refreshable"), description = it.getString("description"), last_access = it.getTalerTimestamp("last_access"), row_id = it.getLong("bearer_token_id"), token_id = it.getLong("bearer_token_id") ) } -} -\ No newline at end of file +} diff --git a/libeufin-bank/src/test/kotlin/CoreBankApiTest.kt b/libeufin-bank/src/test/kotlin/CoreBankApiTest.kt @@ -350,6 +350,74 @@ class CoreBankTokenApiTest { client.delete("/accounts/merchant/token") { headers[HttpHeaders.Authorization] = "Bearer $token" }.assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN) + + // A user cannot delete another account's token by putting its ID + // below a path the user does own. + val customerToken = client.postPw("/accounts/customer/token") { + json { "scope" to "readonly" } + }.assertOkJson<TokenSuccessResponse>().access_token + val customerTokenId = client.getA("/accounts/customer/tokens") + .assertOkJson<TokenInfos>().tokens.first().token_id + client.deleteA("/accounts/merchant/tokens/$customerTokenId") + .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + client.get("/accounts/customer") { + headers[HttpHeaders.Authorization] = "Bearer $customerToken" + }.assertOk() + + // Administrators can delete it when the owning account is explicit. + client.deleteAdmin("/accounts/customer/tokens/$customerTokenId") + .assertNoContent() + } + + @Test + fun refreshOverlapIsFixedAndNonSliding() = bankSetup { db -> + val source = client.postPw("/accounts/merchant/token") { + json { + "scope" to "readonly" + "refreshable" to true + "duration" to obj { "d_us" to 86_400_000_000L } + "description" to "refresh source" + } + }.assertOkJson<TokenSuccessResponse>().access_token + val sourceId = client.getA("/accounts/merchant/tokens") + .assertOkJson<TokenInfos>().tokens + .single { it.description == "refresh source" }.token_id + + suspend fun sourceExpiration(): Instant = db.serializable( + "SELECT expiration_time FROM bearer_tokens WHERE bearer_token_id=?" + ) { + bind(sourceId) + one { it.getLong(1).asInstant() } + } + + val beforeRefresh = Instant.now() + repeat(2) { + client.post("/accounts/merchant/token") { + headers[HttpHeaders.Authorization] = "Bearer $source" + json { + "scope" to "readonly" + "description" to "replacement-$it" + } + }.assertOk() + } + val firstDeadline = sourceExpiration() + assertFalse(firstDeadline.isBefore(beforeRefresh + Duration.ofMinutes(5))) + assertFalse(firstDeadline.isAfter(Instant.now() + Duration.ofMinutes(5))) + + // A retry after a lost response may create another replacement, but + // must not extend the original token's overlap deadline. + client.post("/accounts/merchant/token") { + headers[HttpHeaders.Authorization] = "Bearer $source" + json { + "scope" to "readonly" + "description" to "replacement-lost-response-retry" + } + }.assertOk() + assertEquals(firstDeadline, sourceExpiration()) + val replacements = client.getA("/accounts/merchant/tokens") + .assertOkJson<TokenInfos>().tokens + .count { it.description?.startsWith("replacement-") == true } + assertEquals(3, replacements) } // GET /accounts/USERNAME/tokens @@ -383,6 +451,10 @@ class CoreBankTokenApiTest { assertEquals(2, it.tokens.size) assertEquals("description", it.tokens[0].description) } + val serialized = client.getA("/accounts/customer/tokens") + .assertOk().bodyAsText() + assertContains(serialized, "\"refreshable\"") + assertFalse(serialized.contains("\"isRefreshable\"")) } } @@ -1259,7 +1331,6 @@ class CoreBankTransactionsApiTest { // GET /transactions @Test fun history() = bankSetup { - authRoutine(HttpMethod.Get, "/accounts/merchant/transactions", allowAdmin = true) historyRoutine<BankAccountTransactionsResponse>( url = "/accounts/customer/transactions", ids = { it.transactions.map { it.row_id } }, @@ -1294,6 +1365,35 @@ class CoreBankTransactionsApiTest { ) } + @Test + fun publicHistoryIsAnonymousAndPrivateHistoryIsHidden() = bankSetup { + tx("merchant", "KUDOS:0.1", "customer", "public subject") + client.patchAdmin("/accounts/merchant") { + json { "is_public" to true } + }.assertNoContent() + + val authenticated = client.getA("/accounts/merchant/transactions") + .assertOkJson<BankAccountTransactionsResponse>() + val anonymousResponse = client.get("/accounts/merchant/transactions") + .assertOk() + assertEquals("no-store", anonymousResponse.headers[HttpHeaders.CacheControl]) + val anonymous = anonymousResponse.json<BankAccountTransactionsResponse>() + assertEquals(authenticated, anonymous) + assertEquals("public subject", anonymous.transactions.single().subject) + + client.get("/accounts/customer/transactions") + .assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) + client.get("/accounts/missing/transactions") + .assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) + + // Making an account private takes effect immediately. + client.patchAdmin("/accounts/merchant") { + json { "is_public" to false } + }.assertNoContent() + client.get("/accounts/merchant/transactions") + .assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) + } + // GET /transactions/T_ID @Test fun testById() = bankSetup { @@ -1743,6 +1843,18 @@ class CoreBankWithdrawalApiTest { }.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS) } + // A selected operation is bound to the account that created it. + client.postA("/accounts/merchant/withdrawals") { + json { "amount" to "KUDOS:1" } + }.assertOkJson<BankAccountCreateWithdrawalResponse> { + val uuid = it.withdrawal_id + withdrawalSelect(uuid) + client.postA("/accounts/customer/withdrawals/$uuid/confirm") + .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) + client.postA("/accounts/merchant/withdrawals/$uuid/abort") + .assertNoContent() + } + // Check confirm with amount client.postA("/accounts/merchant/withdrawals") { json {} @@ -1882,7 +1994,7 @@ class CoreBankWithdrawalApiTest { class CoreBankCashoutApiTest { // POST /accounts/{USERNAME}/cashouts @Test - fun create() = bankSetup { + fun create() = bankSetup { db -> authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts") val req = obj { @@ -1918,6 +2030,19 @@ class CoreBankCashoutApiTest { } }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) + // Every monetary field belongs to the idempotency identity. Simulate + // an inconsistent stored credit amount to isolate this comparison + // from conversion validation. + db.serializable( + "UPDATE cashout_operations SET amount_credit=(999,0)::taler_amount WHERE cashout_id=?" + ) { + bind(id) + executeUpdate() + } + client.postA("/accounts/customer/cashouts") { + json(req) + }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED) + // Check exchange account client.postA("/accounts/exchange/cashouts") { json(req) @@ -2456,4 +2581,4 @@ class CoreBankConversionApiTest { client.getAdmin("conversion-rate-classes/1").assertNotImplemented() client.getAdmin("conversion-rate-classes").assertNotImplemented() } -} -\ No newline at end of file +} diff --git a/libeufin-bank/src/test/kotlin/DatabaseTest.kt b/libeufin-bank/src/test/kotlin/DatabaseTest.kt @@ -107,8 +107,8 @@ class DatabaseTest { assertEquals(TanSolveResult.NoRetry, db.tan.solve(this, "bad-code", now)) // Good code fail assertEquals(TanSolveResult.NoRetry, db.tan.solve(this, "good-code", now)) - // New code - assertIs<TanSendResult.Success>(db.tan.send(this, now, 10)) + // Exhausted challenge IDs are terminal and cannot be resent. + assertEquals(TanSendResult.Expired, db.tan.send(this, now, 10)) } // Check retransmission @@ -122,8 +122,8 @@ class DatabaseTest { assertIs<TanSendResult.Send>(db.tan.send(this, retransmit, 10)) // Good code fail because expired assertEquals(TanSolveResult.Expired, db.tan.solve(this, "good-code", expired)) - // No code because expired - assertIs<TanSendResult.Send>(db.tan.send(this, retransmit, 10)) + // Expired challenge IDs are terminal and cannot be resent. + assertEquals(TanSendResult.Expired, db.tan.send(this, expired, 10)) } }} -} -\ No newline at end of file +} diff --git a/libeufin-bank/src/test/kotlin/PreparedTransferApiTest.kt b/libeufin-bank/src/test/kotlin/PreparedTransferApiTest.kt @@ -105,6 +105,8 @@ class PreparedTransferApiTest { }.assertOkJson<SubjectResult> { val uuid = (it.subjects[0] as? TransferSubject.Uri)!!.uri.substringAfterLast('/') client.postA("/accounts/customer/withdrawals/$uuid/confirm").assertNoContent() // reserve + client.postA("/accounts/merchant/withdrawals/$uuid/confirm") + .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce assertBalance("customer", "-KUDOS:1") @@ -283,4 +285,4 @@ class PreparedTransferApiTest { assertBalance("customer", "-KUDOS:1") assertBalance("exchange", "+KUDOS:1") } -} -\ No newline at end of file +}