libeufin

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

commit b5caa5c6b150d31a453827e3c4b62fcf195996c0
parent 8383013aa8defa2205c20ccf76bf5d46edd59ab9
Author: Antoine A <>
Date:   Mon,  6 Jan 2025 14:08:41 +0100

common: improve naming

Diffstat:
Mbank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt | 6+++---
Mbank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt | 10+++++-----
Mbank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt | 2+-
Mbank/src/test/kotlin/WireGatewayApiTest.kt | 18+++++++++---------
Acommon/src/main/kotlin/Subject.kt | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/src/main/kotlin/TalerMessage.kt | 4++--
Dcommon/src/main/kotlin/TxMedatada.kt | 166-------------------------------------------------------------------------------
Acommon/src/test/kotlin/SubjectTest.kt | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcommon/src/test/kotlin/TxMedataTest.kt | 141-------------------------------------------------------------------------------
Mnexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt | 8++++----
Mnexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt | 6+++---
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt | 10+++++-----
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt | 8++++----
Mnexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt | 2+-
Mnexus/src/test/kotlin/WireGatewayApiTest.kt | 18+++++++++---------
Mtestbench/src/test/kotlin/IntegrationTest.kt | 4++--
16 files changed, 355 insertions(+), 355 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt b/bank/src/main/kotlin/tech/libeufin/bank/api/WireGatewayApi.kt @@ -151,7 +151,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { amount: TalerAmount, debitAccount: Payto, subject: String, - metadata: TalerIncomingMetadata + metadata: IncomingSubject ) { cfg.checkRegionalCurrency(amount) val timestamp = Instant.now() @@ -199,7 +199,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { amount = req.amount, debitAccount = req.debit_account, subject = "Admin incoming ${req.reserve_pub}", - metadata = TalerIncomingMetadata(TalerIncomingType.reserve, req.reserve_pub) + metadata = IncomingSubject(IncomingType.reserve, req.reserve_pub) ) } post("/accounts/{USERNAME}/taler-wire-gateway/admin/add-kycauth") { @@ -208,7 +208,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: BankConfig) { amount = req.amount, debitAccount = req.debit_account, subject = "Admin incoming KYC:${req.account_pub}", - metadata = TalerIncomingMetadata(TalerIncomingType.kyc, req.account_pub) + metadata = IncomingSubject(IncomingType.kyc, req.account_pub) ) } } diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt @@ -45,23 +45,23 @@ class ExchangeDAO(private val db: Database) { ON bank_transaction=txs.bank_transaction_id WHERE """) { - val type = it.getEnum<TalerIncomingType>("type") + val type = it.getEnum<IncomingType>("type") when (type) { - TalerIncomingType.reserve -> IncomingReserveTransaction( + IncomingType.reserve -> IncomingReserveTransaction( row_id = it.getLong("bank_transaction_id"), date = it.getTalerTimestamp("transaction_date"), amount = it.getAmount("amount", db.bankCurrency), debit_account = it.getBankPayto("debtor_payto", "debtor_name", db.ctx), reserve_pub = EddsaPublicKey(it.getBytes("metadata")), ) - TalerIncomingType.kyc -> IncomingKycAuthTransaction( + IncomingType.kyc -> IncomingKycAuthTransaction( row_id = it.getLong("bank_transaction_id"), date = it.getTalerTimestamp("transaction_date"), amount = it.getAmount("amount", db.bankCurrency), debit_account = it.getBankPayto("debtor_payto", "debtor_name", db.ctx), account_pub = EddsaPublicKey(it.getBytes("metadata")), ) - TalerIncomingType.wad -> throw UnsupportedOperationException() + IncomingType.wad -> throw UnsupportedOperationException() } } @@ -253,7 +253,7 @@ class ExchangeDAO(private val db: Database) { subject: String, username: String, timestamp: Instant, - metadata: TalerIncomingMetadata + metadata: IncomingSubject ): AddIncomingResult = db.serializable( """ SELECT diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt @@ -113,7 +113,7 @@ class TransactionDAO(private val db: Database) { if (exchangeCreditor && exchangeDebtor) { logger.warn("exchange account $exchangeDebtor sent a manual transaction to exchange account $exchangeCreditor, this should never happens and is not bounced to prevent bouncing loop, may fail in the future") } else if (exchangeCreditor) { - val bounceCause = runCatching { parseIncomingTxMetadata(subject) }.fold( + val bounceCause = runCatching { parseIncomingSubject(subject) }.fold( onSuccess = { metadata -> val registered = conn.withStatement("CALL register_incoming(?, ?::taler_incoming_type, ?, ?)") { setLong(1, creditRowId) diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2024 Taler Systems S.A. + * Copyright (C) 2023-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -279,11 +279,11 @@ class WireGatewayApiTest { ) } - suspend fun ApplicationTestBuilder.talerAddIncomingRoutine(type: TalerIncomingType) { + suspend fun ApplicationTestBuilder.talerAddIncomingRoutine(type: IncomingType) { val (path, key) = when (type) { - TalerIncomingType.reserve -> Pair("add-incoming", "reserve_pub") - TalerIncomingType.kyc -> Pair("add-kycauth", "account_pub") - TalerIncomingType.wad -> throw UnsupportedOperationException() + IncomingType.reserve -> Pair("add-incoming", "reserve_pub") + IncomingType.kyc -> Pair("add-kycauth", "account_pub") + IncomingType.wad -> throw UnsupportedOperationException() } val valid_req = obj { "amount" to "KUDOS:44" @@ -304,12 +304,12 @@ class WireGatewayApiTest { json(valid_req) }.assertOk() - if (type == TalerIncomingType.reserve) { + if (type == IncomingType.reserve) { // Trigger conflict due to reused reserve_pub client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { json(valid_req) }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) - } else if (type == TalerIncomingType.kyc) { + } else if (type == IncomingType.kyc) { // Non conflict on reuse client.postA("/accounts/exchange/taler-wire-gateway/admin/$path") { json(valid_req) @@ -355,13 +355,13 @@ class WireGatewayApiTest { // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-incoming @Test fun addIncoming() = bankSetup { - talerAddIncomingRoutine(TalerIncomingType.reserve) + talerAddIncomingRoutine(IncomingType.reserve) } // POST /accounts/{USERNAME}/taler-wire-gateway/admin/add-kycauth @Test fun addKycAuth() = bankSetup { - talerAddIncomingRoutine(TalerIncomingType.kyc) + talerAddIncomingRoutine(IncomingType.kyc) } @Test diff --git a/common/src/main/kotlin/Subject.kt b/common/src/main/kotlin/Subject.kt @@ -0,0 +1,165 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.common + +import org.bouncycastle.math.ec.rfc8032.Ed25519 + +data class IncomingSubject(val type: IncomingType, val key: EddsaPublicKey) + +/** Base32 quality by proximity to spec and error probability */ +private enum class Base32Quality { + /// Both mixed casing and mixed characters, thats weird + Mixed, + /// Standard but use lowercase, maybe the client shown lowercase in the UI + Standard, + /// Uppercase but mixed characters, its common when making typos + Upper, + /// Both uppercase and use the standard alphabet as it should + UpperStandard; + + companion object { + fun measure(s: String): Base32Quality { + var uppercase = true; + var standard = true; + for (char in s) { + uppercase = uppercase && char.isUpperCase() + standard = standard && Base32Crockford.ALPHABET.contains(char) + } + return if (uppercase && standard) { + Base32Quality.UpperStandard + } else if (uppercase && !standard) { + Base32Quality.Upper + } else if (!uppercase && standard) { + Base32Quality.Standard + } else { + Base32Quality.Mixed + } + } + } +} + +private data class Candidate(val subject: IncomingSubject, val quality: Base32Quality) + +/** + * Extract the public key from an unstructured incoming transfer subject. + * + * When a user enters the transfer object in an unstructured way, for ex in + * their banking UI, they may mistakenly enter separators such as ' \n-+' and + * make typos. + * To parse them while ignoring user errors, we reconstruct valid keys from key + * parts, resolving ambiguities where possible. + **/ +fun parseIncomingSubject(subject: String): IncomingSubject { + /** Parse an incoming subject */ + fun parseSingle(str: String): Candidate? { + // Check key type + val (isKyc, raw) = if (str.startsWith("KYC:")) { + Pair(true, str.substring(4)) + } else { + Pair(false, str) + } + + // Check key validity + val key = try { + EddsaPublicKey(raw) + } catch (e: Exception) { + return null + } + if (!Ed25519.validatePublicKeyFull(key.raw, 0)) { + return null + } + + val quality = Base32Quality.measure(raw); + val type = if (isKyc) IncomingType.kyc else IncomingType.reserve + return Candidate(IncomingSubject(type, key), quality) + } + + // Find and concatenate valid parts of a keys + val parts = mutableListOf<IntRange>() + val concatenated = StringBuilder() + var part: Int? = null + for ((i, c) in subject.withIndex()) { + if (c.isLetterOrDigit() || c == ':') { + part = part ?: i + } else if (part != null) { + val start = concatenated.length + concatenated.append(subject.substring(part until i)); + val end = concatenated.length + parts.add(start until end); + part = null + } + } + if (part != null) { + val start = concatenated.length + concatenated.append(subject.substring(part until subject.length)); + val end = concatenated.length + parts.add(start until end); + } + + // Find best candidates + var best: Candidate? = null + // For each part as a starting point + for ((i, start) in parts.withIndex()) { + // Use progressively longer concatenation + for (end in parts.subList(i, parts.size)) { + val range = start.start..end.endInclusive + val len = range.count() + // Until they are to long to be a key + if (len > 56) { + break; + } + // If the slice is the right size for a key (56B with prefix else 54B) + if (len == 52 || len == 56) { + // Parse the concatenated parts + val slice = concatenated.substring(range) + parseSingle(slice)?.let { other -> + if (best != null) { + if (other.quality > best.quality // We prefer high quality keys + || ( // We prefer prefixed keys over reserve keys + best.subject.type == IncomingType.reserve && + (other.subject.type == IncomingType.kyc || other.subject.type == IncomingType.wad) + )) + { + best = other + } else if (best.subject.key != other.subject.key // If keys are different + && best.quality == other.quality // Of same quality + && !( // And prefixing is diferent + (best.subject.type == IncomingType.kyc || best.subject.type == IncomingType.wad) && + other.subject.type == IncomingType.reserve + )) + { + throw Exception("Found multiple reserve public key") + } + } else { + best = other + } + } + } + } + } + + return best?.subject ?: throw Exception("Missing reserve public key") +} + +/** Extract the reserve public key from an incoming Taler transaction subject */ +fun parseOutgoingSubject(subject: String): Pair<ShortHashCode, ExchangeUrl> { + val (wtid, baseUrl) = subject.splitOnce(" ") ?: throw Exception("Malformed outgoing subject") + return Pair(EddsaPublicKey(wtid), ExchangeUrl(baseUrl)) +} +\ No newline at end of file diff --git a/common/src/main/kotlin/TalerMessage.kt b/common/src/main/kotlin/TalerMessage.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 Taler Systems S.A. * * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -21,7 +21,7 @@ package tech.libeufin.common import kotlinx.serialization.* -enum class TalerIncomingType { +enum class IncomingType { reserve, kyc, wad diff --git a/common/src/main/kotlin/TxMedatada.kt b/common/src/main/kotlin/TxMedatada.kt @@ -1,165 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. - - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.common - -import org.bouncycastle.math.ec.rfc8032.Ed25519 - -data class TalerIncomingMetadata(val type: TalerIncomingType, val key: EddsaPublicKey) - -/** Base32 quality by proximity to spec and error probability */ -private enum class Base32Quality { - /// Both mixed casing and mixed characters, thats weird - Mixed, - /// Standard but use lowercase, maybe the client shown lowercase in the UI - Standard, - /// Uppercase but mixed characters, its common when making typos - Upper, - /// Both uppercase and use the standard alphabet as it should - UpperStandard; - - companion object { - fun measure(s: String): Base32Quality { - var uppercase = true; - var standard = true; - for (char in s) { - uppercase = uppercase && char.isUpperCase() - standard = standard && Base32Crockford.ALPHABET.contains(char) - } - return if (uppercase && standard) { - Base32Quality.UpperStandard - } else if (uppercase && !standard) { - Base32Quality.Upper - } else if (!uppercase && standard) { - Base32Quality.Standard - } else { - Base32Quality.Mixed - } - } - } -} - -private data class Candidate(val subject: TalerIncomingMetadata, val quality: Base32Quality) - -/** - * Extract the public key from an unstructured incoming transfer subject. - * - * When a user enters the transfer object in an unstructured way, for ex in - * their banking UI, they may mistakenly enter separators such as ' \n-+' and - * make typos. - * To parse them while ignoring user errors, we reconstruct valid keys from key - * parts, resolving ambiguities where possible. - **/ -fun parseIncomingTxMetadata(subject: String): TalerIncomingMetadata { - /** Parse an incoming subject */ - fun parseSingle(str: String): Candidate? { - // Check key type - val (isKyc, raw) = if (str.startsWith("KYC:")) { - Pair(true, str.substring(4)) - } else { - Pair(false, str) - } - - // Check key validity - val key = try { - EddsaPublicKey(raw) - } catch (e: Exception) { - return null - } - if (!Ed25519.validatePublicKeyFull(key.raw, 0)) { - return null - } - - val quality = Base32Quality.measure(raw); - val type = if (isKyc) TalerIncomingType.kyc else TalerIncomingType.reserve - return Candidate(TalerIncomingMetadata(type, key), quality) - } - - // Find and concatenate valid parts of a keys - val parts = mutableListOf<IntRange>() - val concatenated = StringBuilder() - var part: Int? = null - for ((i, c) in subject.withIndex()) { - if (c.isLetterOrDigit() || c == ':') { - part = part ?: i - } else if (part != null) { - val start = concatenated.length - concatenated.append(subject.substring(part until i)); - val end = concatenated.length - parts.add(start until end); - part = null - } - } - if (part != null) { - val start = concatenated.length - concatenated.append(subject.substring(part until subject.length)); - val end = concatenated.length - parts.add(start until end); - } - - // Find best candidates - var best: Candidate? = null - // For each part as a starting point - for ((i, start) in parts.withIndex()) { - // Use progressively longer concatenation - for (end in parts.subList(i, parts.size)) { - val range = start.start..end.endInclusive - val len = range.count() - // Until they are to long to be a key - if (len > 56) { - break; - } - // If the slice is the right size for a key (56B with prefix else 54B) - if (len == 52 || len == 56) { - // Parse the concatenated parts - val slice = concatenated.substring(range) - parseSingle(slice)?.let { other -> - if (best != null) { - if (other.quality > best.quality // We prefer high quality keys - || ( // We prefer prefixed keys over reserve keys - best.subject.type == TalerIncomingType.reserve && - (other.subject.type == TalerIncomingType.kyc || other.subject.type == TalerIncomingType.wad) - )) - { - best = other - } else if (best.subject.key != other.subject.key // If keys are different - && best.quality == other.quality // Of same quality - && !( // And prefixing is diferent - (best.subject.type == TalerIncomingType.kyc || best.subject.type == TalerIncomingType.wad) && - other.subject.type == TalerIncomingType.reserve - )) - { - throw Exception("Found multiple reserve public key") - } - } else { - best = other - } - } - } - } - } - - return best?.subject ?: throw Exception("Missing reserve public key") -} - -/** Extract the reserve public key from an incoming Taler transaction subject */ -fun parseOutgoingTxMetadata(subject: String): Pair<ShortHashCode, ExchangeUrl> { - val (wtid, baseUrl) = subject.splitOnce(" ") ?: throw Exception("Malformed outgoing subject") - return Pair(EddsaPublicKey(wtid), ExchangeUrl(baseUrl)) -} -\ No newline at end of file diff --git a/common/src/test/kotlin/SubjectTest.kt b/common/src/test/kotlin/SubjectTest.kt @@ -0,0 +1,140 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2024-2025 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import tech.libeufin.common.* +import kotlin.test.* + +class SubjectTest { + fun assertFailsMsg(msg: String, lambda: () -> Unit) { + val failure = assertFails(lambda) + assertEquals(msg, failure.message) + } + + @Test + fun parse() { + val key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"; + val other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG"; + + for (ty in sequenceOf(IncomingType.reserve, IncomingType.kyc)) { + val prefix = if (ty == IncomingType.kyc) "KYC:" else ""; + val standard = "$prefix$key" + val (standardL, standardR) = standard.chunked(standard.length / 2) + val mixed = "${prefix}4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0" + val (mixedL, mixedR) = mixed.chunked(mixed.length / 2) + val other_standard = "$prefix$other" + val other_mixed = "${prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60" + val key = IncomingSubject(ty, EddsaPublicKey(key)) + + // Check succeed if standard or mixed + for (case in sequenceOf(standard, mixed)) { + for (test in sequenceOf( + "noise $case noise", + "$case noise to the right", + "noise to the left $case", + " $case ", + "noise\n$case\nnoise", + "Test+$case" + )) { + assertEquals(key, parseIncomingSubject(test)) + } + } + + // Check succeed if standard or mixed and split + for ((L, R) in sequenceOf(standardL to standardR, mixedL to mixedR)) { + for (case in sequenceOf( + "left $L$R right", + "left $L $R right", + "left $L-$R right", + "left $L+$R right", + "left $L\n$R right", + "left $L-+\n$R right", + "left $L - $R right", + "left $L + $R right", + "left $L \n $R right", + "left $L - + \n $R right", + )) { + assertEquals(key, parseIncomingSubject(case)) + } + } + + // Check concat parts + for (chunkSize in 1 until standard.length) { + val chunked = standard.chunked(chunkSize).joinToString(" ") + for (case in sequenceOf(chunked, "left ${chunked} right")) { + assertEquals(key, parseIncomingSubject(case)) + } + } + + // Check failed when multiple key + for (case in sequenceOf( + "$standard $other_standard", + "$mixed $other_mixed", + )) { + assertFailsMsg("Found multiple reserve public key") { + parseIncomingSubject(case) + } + } + + // Check accept redundant key + for (case in sequenceOf( + "$standard $standard $mixed $mixed", // Accept redundant key + "$mixedL-$mixedR $standardL-$standardR", + "$standard $other_mixed", // Prefer high quality + )) { + assertEquals(key, parseIncomingSubject(case)) + } + + // Check failure if malformed or missing + for (case in sequenceOf( + "does not contain any reserve", // Check fail if none + standard.substring(1), // Check fail if missing char + "2MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0" // Check fail if not a valid key + )) { + assertFailsMsg("Missing reserve public key") { + parseIncomingSubject(case) + } + } + + if (ty == IncomingType.kyc) { + // Prefer prefixed over unprefixed + for (case in sequenceOf( + "$other $standard", "$other $mixed" + )) { + assertEquals(key, parseIncomingSubject(case)) + } + } + } + } + + /** Test parsing logic using real use case */ + @Test + fun real() { + // Good cases + for ((subject, key) in sequenceOf( + "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60" to "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", + "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG" to "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG", + "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0" to "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0", + )) { + assertEquals( + IncomingSubject(IncomingType.reserve, EddsaPublicKey(key)), + parseIncomingSubject(subject) + ) + } + } +} +\ No newline at end of file diff --git a/common/src/test/kotlin/TxMedataTest.kt b/common/src/test/kotlin/TxMedataTest.kt @@ -1,140 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. - - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - - * LibEuFin is distributed in the hope that it will be useful, but - * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY - * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General - * Public License for more details. - - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -import tech.libeufin.common.* -import kotlin.test.* - -class TxMetadataTest { - fun assertFailsMsg(msg: String, lambda: () -> Unit) { - val failure = assertFails(lambda) - assertEquals(msg, failure.message) - } - - @Test - fun parse() { - val key = "4MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0"; - val other = "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG"; - - for (ty in sequenceOf(TalerIncomingType.reserve, TalerIncomingType.kyc)) { - val prefix = if (ty == TalerIncomingType.kyc) "KYC:" else ""; - val standard = "$prefix$key" - val (standardL, standardR) = standard.chunked(standard.length / 2) - val mixed = "${prefix}4mzt6RS3rvb3b0e2rdmyw0yra3y0vphyv0cyde6xbb0ympfxceg0" - val (mixedL, mixedR) = mixed.chunked(mixed.length / 2) - val other_standard = "$prefix$other" - val other_mixed = "${prefix}TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60" - val key = TalerIncomingMetadata(ty, EddsaPublicKey(key)) - - // Check succeed if standard or mixed - for (case in sequenceOf(standard, mixed)) { - for (test in sequenceOf( - "noise $case noise", - "$case noise to the right", - "noise to the left $case", - " $case ", - "noise\n$case\nnoise", - "Test+$case" - )) { - assertEquals(key, parseIncomingTxMetadata(test)) - } - } - - // Check succeed if standard or mixed and split - for ((L, R) in sequenceOf(standardL to standardR, mixedL to mixedR)) { - for (case in sequenceOf( - "left $L$R right", - "left $L $R right", - "left $L-$R right", - "left $L+$R right", - "left $L\n$R right", - "left $L-+\n$R right", - "left $L - $R right", - "left $L + $R right", - "left $L \n $R right", - "left $L - + \n $R right", - )) { - assertEquals(key, parseIncomingTxMetadata(case)) - } - } - - // Check concat parts - for (chunkSize in 1 until standard.length) { - val chunked = standard.chunked(chunkSize).joinToString(" ") - for (case in sequenceOf(chunked, "left ${chunked} right")) { - assertEquals(key, parseIncomingTxMetadata(case)) - } - } - - // Check failed when multiple key - for (case in sequenceOf( - "$standard $other_standard", - "$mixed $other_mixed", - )) { - assertFailsMsg("Found multiple reserve public key") { - parseIncomingTxMetadata(case) - } - } - - // Check accept redundant key - for (case in sequenceOf( - "$standard $standard $mixed $mixed", // Accept redundant key - "$mixedL-$mixedR $standardL-$standardR", - "$standard $other_mixed", // Prefer high quality - )) { - assertEquals(key, parseIncomingTxMetadata(case)) - } - - // Check failure if malformed or missing - for (case in sequenceOf( - "does not contain any reserve", // Check fail if none - standard.substring(1), // Check fail if missing char - "2MZT6RS3RVB3B0E2RDMYW0YRA3Y0VPHYV0CYDE6XBB0YMPFXCEG0" // Check fail if not a valid key - )) { - assertFailsMsg("Missing reserve public key") { - parseIncomingTxMetadata(case) - } - } - - if (ty == TalerIncomingType.kyc) { - // Prefer prefixed over unprefixed - for (case in sequenceOf( - "$other $standard", "$other $mixed" - )) { - assertEquals(key, parseIncomingTxMetadata(case)) - } - } - } - } - - /** Test parsing logic using real use case */ - @Test - fun real() { - // Good cases - for ((subject, key) in sequenceOf( - "Taler TEGY6d9mh9pgwvwpgs0z0095z854xegfy7j j202yd0esp8p0za60" to "TEGY6d9mh9pgwvwpgs0z0095z854xegfy7jj202yd0esp8p0za60", - "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N 0QT1RCBQ8FXJPZ6RG" to "00Q979QSMJ29S7BJT3DDAVC5A0DR5Z05B7N0QT1RCBQ8FXJPZ6RG", - "Taler NDDCAM9XN4HJZFTBD8V6FNE2FJE8G Y734PJ5AGQMY06C8D4HB3Z0" to "NDDCAM9XN4HJZFTBD8V6FNE2FJE8GY734PJ5AGQMY06C8D4HB3Z0", - )) { - assertEquals( - TalerIncomingMetadata(TalerIncomingType.reserve, EddsaPublicKey(key)), - parseIncomingTxMetadata(subject) - ) - } - } -} -\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/api/WireGatewayApi.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -107,7 +107,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir amount: TalerAmount, debitAccount: Payto, subject: String, - metadata: TalerIncomingMetadata + metadata: IncomingSubject ) { cfg.checkCurrency(amount) val debitAccount = debitAccount.expectRequestIban() @@ -139,7 +139,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir amount = req.amount, debitAccount = req.debit_account, subject = "Manual incoming ${req.reserve_pub}", - metadata = TalerIncomingMetadata(TalerIncomingType.reserve, req.reserve_pub) + metadata = IncomingSubject(IncomingType.reserve, req.reserve_pub) ) } post("/taler-wire-gateway/admin/add-kycauth") { @@ -148,7 +148,7 @@ fun Routing.wireGatewayApi(db: Database, cfg: NexusConfig) = conditional(cfg.wir amount = req.amount, debitAccount = req.debit_account, subject = "Manual incoming KYC:${req.account_pub}", - metadata = TalerIncomingMetadata(TalerIncomingType.kyc, req.account_pub) + metadata = IncomingSubject(IncomingType.kyc, req.account_pub) ) } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/cli/EbicsFetch.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -47,7 +47,7 @@ suspend fun registerOutgoingPayment( payment: OutgoingPayment ): OutgoingRegistrationResult { val metadata: Pair<ShortHashCode, ExchangeUrl>? = payment.subject?.let { - runCatching { parseOutgoingTxMetadata(it) }.getOrNull() + runCatching { parseOutgoingSubject(it) }.getOrNull() } val result = db.payment.registerOutgoing(payment, metadata?.first, metadata?.second) if (result.new) { @@ -132,7 +132,7 @@ suspend fun registerIncomingPayment( } } } - runCatching { parseIncomingTxMetadata(payment.subject) }.fold( + runCatching { parseIncomingSubject(payment.subject) }.fold( onSuccess = { metadata -> when (val res = db.payment.registerTalerableIncoming(payment, metadata)) { IncomingRegistrationResult.ReservePubReuse -> bounce("reverse pub reuse") diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024 Taler Systems S.A. + * Copyright (C) 2024-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -44,9 +44,9 @@ class ExchangeDAO(private val db: Database) { JOIN incoming_transactions USING(incoming_transaction_id) WHERE """, "incoming_transaction_id") { - val type = it.getEnum<TalerIncomingType>("type") + val type = it.getEnum<IncomingType>("type") when (type) { - TalerIncomingType.reserve -> IncomingReserveTransaction( + IncomingType.reserve -> IncomingReserveTransaction( row_id = it.getLong("incoming_transaction_id"), date = it.getTalerTimestamp("execution_time"), amount = it.getAmount("amount", db.bankCurrency), @@ -54,7 +54,7 @@ class ExchangeDAO(private val db: Database) { debit_account = it.getString("debit_payto"), reserve_pub = EddsaPublicKey(it.getBytes("metadata")), ) - TalerIncomingType.kyc -> IncomingKycAuthTransaction( + IncomingType.kyc -> IncomingKycAuthTransaction( row_id = it.getLong("incoming_transaction_id"), date = it.getTalerTimestamp("execution_time"), amount = it.getAmount("amount", db.bankCurrency), @@ -62,7 +62,7 @@ class ExchangeDAO(private val db: Database) { debit_account = it.getString("debit_payto"), account_pub = EddsaPublicKey(it.getBytes("metadata")), ) - TalerIncomingType.wad -> throw UnsupportedOperationException() + IncomingType.wad -> throw UnsupportedOperationException() } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt @@ -50,7 +50,7 @@ class ListDAO(private val db: Database) { """ ) { all { - val type = it.getOptEnum<TalerIncomingType>("type") + val type = it.getOptEnum<IncomingType>("type") IncomingTxMetadata( date = it.getLong("execution_time").asInstant(), amount = it.getDecimal("amount"), @@ -60,9 +60,9 @@ class ListDAO(private val db: Database) { id = it.getString("bank_id"), talerable = when (type) { null -> if (it.getBoolean("bounced")) "bounced" else "" - TalerIncomingType.reserve -> "reserve ${EddsaPublicKey(it.getBytes("metadata"))}" - TalerIncomingType.kyc -> "kyc ${EddsaPublicKey(it.getBytes("metadata"))}" - TalerIncomingType.wad -> throw UnsupportedOperationException() + IncomingType.reserve -> "reserve ${EddsaPublicKey(it.getBytes("metadata"))}" + IncomingType.kyc -> "kyc ${EddsaPublicKey(it.getBytes("metadata"))}" + IncomingType.wad -> throw UnsupportedOperationException() } ) } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/db/PaymentDAO.kt @@ -116,7 +116,7 @@ class PaymentDAO(private val db: Database) { /** Register an talerable incoming payment */ suspend fun registerTalerableIncoming( payment: IncomingPayment, - metadata: TalerIncomingMetadata + metadata: IncomingSubject ): IncomingRegistrationResult = db.serializable( """ SELECT out_reserve_pub_reuse, out_found, out_tx_id diff --git a/nexus/src/test/kotlin/WireGatewayApiTest.kt b/nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2024 Taler Systems S.A. + * Copyright (C) 2023-2025 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -238,11 +238,11 @@ class WireGatewayApiTest { ) } - suspend fun ApplicationTestBuilder.talerAddIncomingRoutine(type: TalerIncomingType) { + suspend fun ApplicationTestBuilder.talerAddIncomingRoutine(type: IncomingType) { val (path, key) = when (type) { - TalerIncomingType.reserve -> Pair("add-incoming", "reserve_pub") - TalerIncomingType.kyc -> Pair("add-kycauth", "account_pub") - TalerIncomingType.wad -> throw UnsupportedOperationException() + IncomingType.reserve -> Pair("add-incoming", "reserve_pub") + IncomingType.kyc -> Pair("add-kycauth", "account_pub") + IncomingType.wad -> throw UnsupportedOperationException() } val valid_req = obj { "amount" to "CHF:44" @@ -257,12 +257,12 @@ class WireGatewayApiTest { json(valid_req) }.assertOk() - if (type == TalerIncomingType.reserve) { + if (type == IncomingType.reserve) { // Trigger conflict due to reused reserve_pub client.postA("/taler-wire-gateway/admin/$path") { json(valid_req) }.assertConflict(TalerErrorCode.BANK_DUPLICATE_RESERVE_PUB_SUBJECT) - } else if (type == TalerIncomingType.kyc) { + } else if (type == IncomingType.kyc) { // Non conflict on reuse client.postA("/taler-wire-gateway/admin/$path") { json(valid_req) @@ -299,13 +299,13 @@ class WireGatewayApiTest { // POST /taler-wire-gateway/admin/add-incoming @Test fun addIncoming() = serverSetup { - talerAddIncomingRoutine(TalerIncomingType.reserve) + talerAddIncomingRoutine(IncomingType.reserve) } // POST /taler-wire-gateway/admin/add-kycauth @Test fun addKycAuth() = serverSetup { - talerAddIncomingRoutine(TalerIncomingType.kyc) + talerAddIncomingRoutine(IncomingType.kyc) } @Test diff --git a/testbench/src/test/kotlin/IntegrationTest.kt b/testbench/src/test/kotlin/IntegrationTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2024 Taler Systems S.A. + * Copyright (C) 2023-2025 Taler Systems S.A. * * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -217,7 +217,7 @@ class IntegrationTest { }.assertNoContent() assertException("ERROR: cashin failed: admin balance insufficient") { - db.payment.registerTalerableIncoming(reservePayment, TalerIncomingMetadata(TalerIncomingType.reserve, reservePub)) + db.payment.registerTalerableIncoming(reservePayment, IncomingSubject(IncomingType.reserve, reservePub)) } // Allow admin debt