diff options
author | Antoine A <> | 2024-01-09 01:26:57 +0000 |
---|---|---|
committer | Antoine A <> | 2024-01-09 01:26:57 +0000 |
commit | 6bac5cf1c5d642d6e0a0a9afbc6c90cb0291c78b (patch) | |
tree | 12f1de2713b85ced196ef1771a26a1e871c897b2 | |
parent | f24be2dae4b9d081825d9409f9dd00afca7181bc (diff) | |
download | libeufin-6bac5cf1c5d642d6e0a0a9afbc6c90cb0291c78b.tar.gz libeufin-6bac5cf1c5d642d6e0a0a9afbc6c90cb0291c78b.tar.bz2 libeufin-6bac5cf1c5d642d6e0a0a9afbc6c90cb0291c78b.zip |
Improve ebics cli error handling and improve logic
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | integration/src/main/kotlin/Main.kt | 24 | ||||
-rw-r--r-- | nexus/conf/test.conf | 13 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt | 127 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt | 183 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt | 43 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt | 59 | ||||
-rw-r--r-- | nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt | 8 | ||||
-rw-r--r-- | nexus/src/test/kotlin/CliTest.kt | 98 | ||||
-rw-r--r-- | nexus/src/test/kotlin/Keys.kt | 16 | ||||
-rw-r--r-- | nexus/src/test/kotlin/PostFinance.kt | 219 | ||||
-rw-r--r-- | util/src/main/kotlin/Cli.kt | 13 | ||||
-rw-r--r-- | util/src/main/kotlin/DB.kt | 2 |
13 files changed, 327 insertions, 479 deletions
@@ -4,6 +4,7 @@ /sandbox/bin/ /util/bin/ nexus/libeufin-nexus-dev +nexus/test integration/test integration/config.json sandbox/libeufin-sandbox-dev diff --git a/integration/src/main/kotlin/Main.kt b/integration/src/main/kotlin/Main.kt index 074803a5..a18bf461 100644 --- a/integration/src/main/kotlin/Main.kt +++ b/integration/src/main/kotlin/Main.kt @@ -35,6 +35,7 @@ import java.time.Instant import kotlinx.coroutines.runBlocking import io.ktor.client.request.* import net.taler.wallet.crypto.Base32Crockford +import kotlin.io.path.* fun randBytes(lenght: Int): ByteArray { val bytes = ByteArray(lenght) @@ -56,30 +57,29 @@ fun ask(question: String): String? { } fun CliktCommandTestResult.assertOk(msg: String? = null) { - assertEquals(0, statusCode, "msg\n$output") - println(output) + assertEquals(0, statusCode, msg) } fun CliktCommandTestResult.assertErr(msg: String? = null) { - assertEquals(1, statusCode, "msg\n$output") - println(output) + assertEquals(1, statusCode, msg) } class PostFinanceCli : CliktCommand("Run tests on postfinance", name="postfinance") { override fun run() { runBlocking { - Files.createDirectories(Paths.get("test/postfinance")) + Path("test/postfinance").createDirectories() val conf = "conf/postfinance.conf" - val clientKeysPath = Paths.get("test/postfinance/client-keys.json") - val bankKeysPath = Paths.get("test/postfinance/bank-keys.json") - - var hasClientKeys = Files.exists(clientKeysPath) - var hasBankKeys = Files.exists(bankKeysPath) + val cfg = loadConfig(conf) + val clientKeysPath = Path(cfg.requireString("nexus-ebics", "client_private_keys_file")) + val bankKeysPath = Path(cfg.requireString("nexus-ebics", "bank_public_keys_file")) + + var hasClientKeys = clientKeysPath.exists() + var hasBankKeys = bankKeysPath.exists() if (hasClientKeys || hasBankKeys) { if (ask("Reset keys ? y/n>") == "y") { - if (hasClientKeys) Files.deleteIfExists(clientKeysPath) - if (hasBankKeys) Files.deleteIfExists(bankKeysPath) + if (hasClientKeys) clientKeysPath.deleteIfExists() + if (hasBankKeys) bankKeysPath.deleteIfExists() hasClientKeys = false hasBankKeys = false } diff --git a/nexus/conf/test.conf b/nexus/conf/test.conf new file mode 100644 index 00000000..0235dde1 --- /dev/null +++ b/nexus/conf/test.conf @@ -0,0 +1,13 @@ +[nexus-ebics] +currency = CHF +BANK_DIALECT = postfinance +HOST_BASE_URL = https://isotest.postfinance.ch/ebicsweb/ebicsweb +BANK_PUBLIC_KEYS_FILE = test/tmp/bank-keys.json +CLIENT_PRIVATE_KEYS_FILE = test/tmp/client-keys.json +IBAN = CH7789144474425692816 +HOST_ID = PFEBICS +USER_ID = PFC00563 +PARTNER_ID = PFC00563 + +[nexus-postgres] +CONFIG = postgres:///libeufincheck
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt index 72975e38..ba7956e3 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsFetch.kt @@ -528,9 +528,7 @@ class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 noti */ override fun run() = cliCmd(logger) { val cfg: EbicsSetupConfig = extractEbicsConfig(common.config) - val dbCfg = cfg.config.dbConfig() - val db = Database(dbCfg.dbConnStr) // Deciding what to download. var whichDoc = SupportedDocument.CAMT_054 @@ -538,78 +536,81 @@ class EbicsFetch: CliktCommand("Fetches bank records. Defaults to camt.054 noti if (onlyReports) whichDoc = SupportedDocument.CAMT_052 if (onlyStatements) whichDoc = SupportedDocument.CAMT_053 if (onlyLogs) whichDoc = SupportedDocument.PAIN_002_LOGS - if (parse || import) { - logger.debug("Reading from STDIN, running in debug mode. Not involving the database.") - val maybeStdin = generateSequence(::readLine).joinToString("\n") - when(whichDoc) { - SupportedDocument.CAMT_054 -> { - val incomingTxs = mutableListOf<IncomingPayment>() - val outgoingTxs = mutableListOf<OutgoingPayment>() - parseTxNotif(maybeStdin, cfg.currency, incomingTxs, outgoingTxs) - println(incomingTxs) - println(outgoingTxs) - if (import) { - runBlocking { - incomingTxs.forEach { - ingestIncomingPayment(db, it) - } - outgoingTxs.forEach { - ingestOutgoingPayment(db, it) + Database(dbCfg.dbConnStr).use { db -> + if (parse || import) { + logger.debug("Reading from STDIN, running in debug mode. Not involving the database.") + val maybeStdin = generateSequence(::readLine).joinToString("\n") + when(whichDoc) { + SupportedDocument.CAMT_054 -> { + val incomingTxs = mutableListOf<IncomingPayment>() + val outgoingTxs = mutableListOf<OutgoingPayment>() + + parseTxNotif(maybeStdin, cfg.currency, incomingTxs, outgoingTxs) + println(incomingTxs) + println(outgoingTxs) + if (import) { + runBlocking { + incomingTxs.forEach { + ingestIncomingPayment(db, it) + } + outgoingTxs.forEach { + ingestOutgoingPayment(db, it) + } } } } + else -> throw Exception("Parsing $whichDoc not supported") } - else -> throw Error("Parsing $whichDoc not supported") + return@cliCmd } - return@cliCmd - } - val (clientKeys, bankKeys) = expectFullKeys(cfg) - val ctx = FetchContext( - cfg, - HttpClient(), - clientKeys, - bankKeys, - whichDoc, - EbicsVersion.three, - ebicsExtraLog - ) - if (transient) { - logger.info("Transient mode: fetching once and returning.") - val pinnedStartVal = pinnedStart - val pinnedStartArg = if (pinnedStartVal != null) { - logger.debug("Pinning start date to: $pinnedStartVal") - // Converting YYYY-MM-DD to Instant. - LocalDate.parse(pinnedStartVal).atStartOfDay(ZoneId.of("UTC")).toInstant() - } else null - ctx.pinnedStart = pinnedStartArg - if (whichDoc == SupportedDocument.PAIN_002_LOGS) - ctx.ebicsVersion = EbicsVersion.two - runBlocking { - fetchDocuments(db, ctx) - } - return@cliCmd - } - val configValue = cfg.config.requireString("nexus-fetch", "frequency") - val frequencySeconds = checkFrequency(configValue) - val frequency: NexusFrequency = NexusFrequency(frequencySeconds, configValue) - logger.debug("Running with a frequency of ${frequency.fromConfig}") - if (frequency.inSeconds == 0) { - logger.warn("Long-polling not implemented, running therefore in transient mode") - runBlocking { - fetchDocuments(db, ctx) + val (clientKeys, bankKeys) = expectFullKeys(cfg) + val ctx = FetchContext( + cfg, + HttpClient(), + clientKeys, + bankKeys, + whichDoc, + EbicsVersion.three, + ebicsExtraLog + ) + if (transient) { + logger.info("Transient mode: fetching once and returning.") + val pinnedStartVal = pinnedStart + val pinnedStartArg = if (pinnedStartVal != null) { + logger.debug("Pinning start date to: $pinnedStartVal") + // Converting YYYY-MM-DD to Instant. + LocalDate.parse(pinnedStartVal).atStartOfDay(ZoneId.of("UTC")).toInstant() + } else null + ctx.pinnedStart = pinnedStartArg + if (whichDoc == SupportedDocument.PAIN_002_LOGS) + ctx.ebicsVersion = EbicsVersion.two + runBlocking { + fetchDocuments(db, ctx) + } + return@cliCmd } - return@cliCmd - } - fixedRateTimer( - name = "ebics submit period", - period = (frequency.inSeconds * 1000).toLong(), - action = { + val configValue = cfg.config.requireString("nexus-fetch", "frequency") + val frequencySeconds = checkFrequency(configValue) + val frequency: NexusFrequency = NexusFrequency(frequencySeconds, configValue) + logger.debug("Running with a frequency of ${frequency.fromConfig}") + if (frequency.inSeconds == 0) { + logger.warn("Long-polling not implemented, running therefore in transient mode") runBlocking { fetchDocuments(db, ctx) } + return@cliCmd } - ) + fixedRateTimer( + name = "ebics submit period", + period = (frequency.inSeconds * 1000).toLong(), + action = { + runBlocking { + fetchDocuments(db, ctx) + } + } + ) + } } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt index 5fd0c985..40a09d7f 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSetup.kt @@ -33,6 +33,9 @@ import tech.libeufin.util.* import tech.libeufin.util.ebics_h004.HTDResponseOrderData import java.time.Instant import kotlin.reflect.typeOf +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import kotlin.io.path.* /** * Writes the JSON content to disk. Used when we create or update @@ -40,23 +43,23 @@ import kotlin.reflect.typeOf * silently what's found under the given location! * * @param obj the class representing the JSON content to store to disk. - * @param location where to store `obj` - * @return true in case of success, false otherwise. + * @param path where to store `obj` */ -inline fun <reified T> syncJsonToDisk(obj: T, location: String): Boolean { - val fileContent = try { +inline fun <reified T> syncJsonToDisk(obj: T, path: String) { + val content = try { myJson.encodeToString(obj) } catch (e: Exception) { - logger.error("Could not encode the input '${typeOf<T>()}' to JSON, detail: ${e.message}") - return false + throw Exception("Could not encode the input '${typeOf<T>()}' to JSON", e) } try { - File(location).writeText(fileContent) + // Write to temp file then rename to enable atomicity when possible + val path = Path(path).absolute() + val tmp = Files.createTempFile(path.parent, "tmp_", "_${path.fileName}") + tmp.writeText(content) + tmp.moveTo(path, StandardCopyOption.REPLACE_EXISTING); } catch (e: Exception) { - logger.error("Could not write JSON content at $location, detail: ${e.message}") - return false + throw Exception("Could not write JSON content at $path", e) } - return true } /** @@ -72,42 +75,28 @@ fun generateNewKeys(): ClientPrivateKeysFile = submitted_hia = false, submitted_ini = false ) -/** - * Conditionally generates the client private keys and stores them - * to disk, if the file does not exist already. Does nothing if the - * file exists. - * - * @param filename keys file location - * @return true if the keys file existed already or its creation - * went through, false for any error. - */ -fun maybeCreatePrivateKeysFile(filename: String): Boolean { - val f = File(filename) - // NOT overriding any file at the wanted location. - if (f.exists()) { - logger.debug("Private key file found at: $filename.") - return true - } - val newKeys = generateNewKeys() - if (!syncJsonToDisk(newKeys, filename)) - return false - logger.info("New client keys created at: $filename") - return true -} /** * Obtains the client private keys, regardless of them being * created for the first time, or read from an existing file * on disk. * - * @param location path to the file that contains the keys. - * @return true if the operation succeeds, false otherwise. + * @param path path to the file that contains the keys. + * @return current or new client keys */ -private fun preparePrivateKeys(location: String): ClientPrivateKeysFile? { - if (!maybeCreatePrivateKeysFile(location)) { - throw Error("Could not create client keys at $location") +private fun preparePrivateKeys(path: String): ClientPrivateKeysFile { + // If exists load from disk + val current = loadPrivateKeysFromDisk(path) + if (current != null) return current + // Else create new keys + try { + val newKeys = generateNewKeys() + syncJsonToDisk(newKeys, path) + logger.info("New client keys created at: $path") + return newKeys + } catch (e: Exception) { + throw Exception("Could not create client keys at $path", e) } - return loadPrivateKeysFromDisk(location) // loads what found at location. } /** @@ -157,47 +146,40 @@ private fun askUserToAcceptKeys(bankKeys: BankPublicKeysFile): Boolean { * * @param cfg used to get the location of the bank keys file. * @param bankKeys bank response to the HPB message. - * @return true if the keys were stored to disk (as "not accepted"), - * false if the storage failed or the content was invalid. */ private fun handleHpbResponse( cfg: EbicsSetupConfig, bankKeys: EbicsKeyManagementResponseContent -): Boolean { +) { val hpbBytes = bankKeys.orderData // silences compiler. if (hpbBytes == null) { - logger.error("HPB content not found in a EBICS response with successful return codes.") - return false + throw Exception("HPB content not found in a EBICS response with successful return codes.") } val hpbObj = try { parseEbicsHpbOrder(hpbBytes) - } - catch (e: Exception) { - logger.error("HPB response content seems invalid.") - return false + } catch (e: Exception) { + throw Exception("HPB response content seems invalid: e") } val encPub = try { CryptoUtil.loadRsaPublicKey(hpbObj.encryptionPubKey.encoded) } catch (e: Exception) { - logger.error("Could not import bank encryption key from HPB response, detail: ${e.message}") - return false + throw Exception("Could not import bank encryption key from HPB response", e) } val authPub = try { CryptoUtil.loadRsaPublicKey(hpbObj.authenticationPubKey.encoded) } catch (e: Exception) { - logger.error("Could not import bank authentication key from HPB response, detail: ${e.message}") - return false + throw Exception("Could not import bank authentication key from HPB response", e) } val json = BankPublicKeysFile( bank_authentication_public_key = authPub, bank_encryption_public_key = encPub, accepted = false ) - if (!syncJsonToDisk(json, cfg.bankPublicKeysFilename)) { - logger.error("Failed to persist the bank keys to disk at: ${cfg.bankPublicKeysFilename}") - return false + try { + syncJsonToDisk(json, cfg.bankPublicKeysFilename) + } catch (e: Exception) { + throw Exception("Failed to persist the bank keys to disk", e) } - return true } /** @@ -211,15 +193,13 @@ private fun handleHpbResponse( * @param orderType INI or HIA. * @param autoAcceptBankKeys only given in case of HPB. Expresses * the --auto-accept-key CLI flag. - * @return true if the message fulfilled its purpose AND the state - * on disk was accordingly updated, or false otherwise. */ suspend fun doKeysRequestAndUpdateState( cfg: EbicsSetupConfig, privs: ClientPrivateKeysFile, client: HttpClient, orderType: KeysOrderType -): Boolean { +) { logger.debug("Doing key request ${orderType.name}") val req = when(orderType) { KeysOrderType.INI -> generateIniMessage(cfg, privs) @@ -228,33 +208,29 @@ suspend fun doKeysRequestAndUpdateState( } val xml = client.postToBank(cfg.hostBaseUrl, req) if (xml == null) { - logger.error("Could not POST the ${orderType.name} message to the bank") - return false + throw Exception("Could not POST the ${orderType.name} message to the bank") } val ebics = parseKeysMgmtResponse(privs.encryption_private_key, xml) if (ebics == null) { - logger.error("Could not get any EBICS from the bank ${orderType.name} response ($xml).") - return false + throw Exception("Could not get any EBICS from the bank ${orderType.name} response ($xml).") } if (ebics.technicalReturnCode != EbicsReturnCode.EBICS_OK) { - logger.error("EBICS ${orderType.name} failed with code: ${ebics.technicalReturnCode}") - return false + throw Exception("EBICS ${orderType.name} failed with code: ${ebics.technicalReturnCode}") } if (ebics.bankReturnCode != EbicsReturnCode.EBICS_OK) { - logger.error("EBICS ${orderType.name} reached the bank, but could not be fulfilled, error code: ${ebics.bankReturnCode}") - return false + throw Exception("EBICS ${orderType.name} reached the bank, but could not be fulfilled, error code: ${ebics.bankReturnCode}") } - when(orderType) { + when (orderType) { KeysOrderType.INI -> privs.submitted_ini = true KeysOrderType.HIA -> privs.submitted_hia = true KeysOrderType.HPB -> return handleHpbResponse(cfg, ebics) } - if (!syncJsonToDisk(privs, cfg.clientPrivateKeysFilename)) { - logger.error("Could not update the ${orderType.name} state on disk") - return false + try { + syncJsonToDisk(privs, cfg.clientPrivateKeysFilename) + } catch (e: Exception) { + throw Exception("Could not update the ${orderType.name} state on disk", e) } - return true } /** @@ -279,12 +255,12 @@ private fun makePdf(privs: ClientPrivateKeysFile, cfg: EbicsSetupConfig) { val pdf = generateKeysPdf(privs, cfg) val pdfFile = File("/tmp/libeufin-nexus-keys-${Instant.now().epochSecond}.pdf") if (pdfFile.exists()) { - throw Error("PDF file exists already at: ${pdfFile.path}, not overriding it") + throw Exception("PDF file exists already at: ${pdfFile.path}, not overriding it") } try { pdfFile.writeBytes(pdf) } catch (e: Exception) { - throw Error("Could not write PDF to ${pdfFile}, detail: ${e.message}") + throw Exception("Could not write PDF to ${pdfFile}, detail: ${e.message}") } println("PDF file with keys hex encoding created at: $pdfFile") } @@ -333,54 +309,39 @@ class EbicsSetup: CliktCommand("Set up the EBICS subscriber") { return@cliCmd } // Config is sane. Go (maybe) making the private keys. - val privsMaybe = preparePrivateKeys(cfg.clientPrivateKeysFilename) - if (privsMaybe == null) { - throw Error("Private keys preparation failed.") - } + val clientKeys = preparePrivateKeys(cfg.clientPrivateKeysFilename) val httpClient = HttpClient() // Privs exist. Upload their pubs - val keysNotSub = !privsMaybe.submitted_ini || !privsMaybe.submitted_hia + val keysNotSub = !clientKeys.submitted_ini || !clientKeys.submitted_hia runBlocking { - if ((!privsMaybe.submitted_ini) || forceKeysResubmission) - doKeysRequestAndUpdateState(cfg, privsMaybe, httpClient, KeysOrderType.INI).apply { if (!this) throw Error() } - if ((!privsMaybe.submitted_hia) || forceKeysResubmission) - doKeysRequestAndUpdateState(cfg, privsMaybe, httpClient, KeysOrderType.HIA).apply { if (!this) throw Error() } - } - // Reloading new state from disk if any upload (and therefore a disk write) actually took place - val haveSubmitted = forceKeysResubmission || keysNotSub - val privs = if (haveSubmitted) { - logger.info("Keys submitted to the bank, at ${cfg.hostBaseUrl}") - loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) - } else privsMaybe - if (privs == null) { - throw Error("Could not reload private keys from disk after submission") - } - // Really both must be submitted here. - if ((!privs.submitted_hia) || (!privs.submitted_ini)) { - throw Error("Cannot continue with non-submitted client keys.") + if ((!clientKeys.submitted_ini) || forceKeysResubmission) + doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, KeysOrderType.INI) + if ((!clientKeys.submitted_hia) || forceKeysResubmission) + doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, KeysOrderType.HIA) } // Eject PDF if the keys were submitted for the first time, or the user asked. - if (keysNotSub || generateRegistrationPdf) makePdf(privs, cfg) + if (keysNotSub || generateRegistrationPdf) makePdf(clientKeys, cfg) // Checking if the bank keys exist on disk. val bankKeysFile = File(cfg.bankPublicKeysFilename) if (!bankKeysFile.exists()) { - val areKeysOnDisk = runBlocking { - doKeysRequestAndUpdateState( - cfg, - privs, - httpClient, - KeysOrderType.HPB - ) - } - if (!areKeysOnDisk) { - throw Error("Could not download bank keys. Send client keys (and/or related PDF document with --generate-registration-pdf) to the bank.") + runBlocking { + try { + doKeysRequestAndUpdateState( + cfg, + clientKeys, + httpClient, + KeysOrderType.HPB + ) + } catch (e: Exception) { + throw Exception("Could not download bank keys. Send client keys (and/or related PDF document with --generate-registration-pdf) to the bank", e) + } } logger.info("Bank keys stored at ${cfg.bankPublicKeysFilename}") } // bank keys made it to the disk, check if they're accepted. val bankKeysMaybe = loadBankKeys(cfg.bankPublicKeysFilename) if (bankKeysMaybe == null) { - throw Error("Although previous checks, could not load the bank keys file from: ${cfg.bankPublicKeysFilename}") + throw Exception("Although previous checks, could not load the bank keys file from: ${cfg.bankPublicKeysFilename}") } if (!bankKeysMaybe.accepted) { @@ -389,10 +350,12 @@ class EbicsSetup: CliktCommand("Set up the EBICS subscriber") { else bankKeysMaybe.accepted = askUserToAcceptKeys(bankKeysMaybe) if (!bankKeysMaybe.accepted) { - throw Error("Cannot successfully finish the setup without accepting the bank keys.") + throw Exception("Cannot successfully finish the setup without accepting the bank keys.") } - if (!syncJsonToDisk(bankKeysMaybe, cfg.bankPublicKeysFilename)) { - throw Error("Could not set bank keys as accepted on disk.") + try { + syncJsonToDisk(bankKeysMaybe, cfg.bankPublicKeysFilename) + } catch (e: Exception) { + throw Exception("Could not set bank keys as accepted on disk.", e) } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt index b9bc2c38..061e64f7 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/EbicsSubmit.kt @@ -123,7 +123,7 @@ private fun maybeLog( ) // Very rare: same pain.001 should not be submitted twice in the same microsecond. if (f.exists()) { - throw Error("pain.001 log file exists already at: $f") + throw Exception("pain.001 log file exists already at: $f") } f.writeText(xml) } @@ -271,7 +271,6 @@ class EbicsSubmit : CliktCommand("Submits any initiated payment found in the dat override fun run() = cliCmd(logger) { val cfg: EbicsSetupConfig = extractEbicsConfig(common.config) val dbCfg = cfg.config.dbConfig() - val db = Database(dbCfg.dbConnStr) val (clientKeys, bankKeys) = expectFullKeys(cfg) val ctx = SubmissionContext( cfg = cfg, @@ -295,26 +294,28 @@ class EbicsSubmit : CliktCommand("Submits any initiated payment found in the dat } return@cliCmd } - if (transient) { - logger.info("Transient mode: submitting what found and returning.") - submitBatch(ctx, db) - return@cliCmd - } - val configValue = cfg.config.requireString("nexus-submit", "frequency") - val frequencySeconds = checkFrequency(configValue) - val frequency: NexusFrequency = NexusFrequency(frequencySeconds, configValue) - logger.debug("Running with a frequency of ${frequency.fromConfig}") - if (frequency.inSeconds == 0) { - logger.warn("Long-polling not implemented, running therefore in transient mode") - submitBatch(ctx, db) - return@cliCmd - } - fixedRateTimer( - name = "ebics submit period", - period = (frequency.inSeconds * 1000).toLong(), - action = { + Database(dbCfg.dbConnStr).use { db -> + if (transient) { + logger.info("Transient mode: submitting what found and returning.") submitBatch(ctx, db) + return@cliCmd } - ) + val configValue = cfg.config.requireString("nexus-submit", "frequency") + val frequencySeconds = checkFrequency(configValue) + val frequency: NexusFrequency = NexusFrequency(frequencySeconds, configValue) + logger.debug("Running with a frequency of ${frequency.fromConfig}") + if (frequency.inSeconds == 0) { + logger.warn("Long-polling not implemented, running therefore in transient mode") + submitBatch(ctx, db) + return@cliCmd + } + fixedRateTimer( + name = "ebics submit period", + period = (frequency.inSeconds * 1000).toLong(), + action = { + submitBatch(ctx, db) + } + ) + } } }
\ No newline at end of file diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt index ab9fe2c6..3a535aff 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/Main.kt @@ -48,6 +48,7 @@ import tech.libeufin.nexus.ebics.* import tech.libeufin.util.* import java.security.interfaces.RSAPrivateCrtKey import java.security.interfaces.RSAPublicKey +import java.io.FileNotFoundException val NEXUS_CONFIG_SOURCE = ConfigSource("libeufin", "libeufin-nexus", "libeufin-nexus") val logger: Logger = LoggerFactory.getLogger("tech.libeufin.nexus") @@ -280,16 +281,16 @@ fun expectFullKeys( cfg: EbicsSetupConfig ): Pair<ClientPrivateKeysFile, BankPublicKeysFile> { val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) - if (clientKeys == null || - !clientKeys.submitted_ini || - !clientKeys.submitted_hia) { - throw Error("Cannot operate without or with unsubmitted subscriber keys." + - " Run 'libeufin-nexus ebics-setup' first.") + if (clientKeys == null) { + throw Exception("Cannot operate without client keys. Missing '${cfg.clientPrivateKeysFilename}' file. Run 'libeufin-nexus ebics-setup' first") + } else if (!clientKeys.submitted_ini || !clientKeys.submitted_hia) { + throw Exception("Cannot operate with unsubmitted client keys, run 'libeufin-nexus ebics-setup' first") } val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename) - if (bankKeys == null || !bankKeys.accepted) { - throw Error("Cannot operate without or with unaccepted bank keys." + - " Run 'libeufin-nexus ebics-setup' until accepting the bank keys.") + if (bankKeys == null) { + throw Exception("Cannot operate without bank keys. Missing '${cfg.bankPublicKeysFilename}' file. run 'libeufin-nexus ebics-setup' first") + } else if (!bankKeys.accepted) { + throw Exception("Cannot operate with unaccepted bank keys, run 'libeufin-nexus ebics-setup' until accepting the bank keys") } return Pair(clientKeys, bankKeys) } @@ -299,27 +300,20 @@ fun expectFullKeys( * * @param location the keys file location. * @return the internal JSON representation of the keys file, - * or null on failures. + * or null if the file does not exist */ fun loadBankKeys(location: String): BankPublicKeysFile? { - val f = File(location) - if (!f.exists()) { - logger.error("Could not find the bank keys file at: $location") + val content = try { + File(location).readText() + } catch (e: FileNotFoundException) { return null - } - val fileContent = try { - f.readText() // read from disk. } catch (e: Exception) { - logger.error("Could not read the bank keys file from disk, detail: ${e.message}") - return null + throw Exception("Could not read the bank keys file from disk", e) } return try { - myJson.decodeFromString(fileContent) // Parse into JSON. + myJson.decodeFromString(content) } catch (e: Exception) { - logger.error(e.message) - @OptIn(InternalAPI::class) // enables message below. - logger.error(e.rootCause?.message) // actual useful message mentioning failing fields - return null + throw Exception("Could not decode bank keys", e) } } @@ -328,27 +322,20 @@ fun loadBankKeys(location: String): BankPublicKeysFile? { * * @param location the keys file location. * @return the internal JSON representation of the keys file, - * or null on failures. + * or null if the file does not exist */ fun loadPrivateKeysFromDisk(location: String): ClientPrivateKeysFile? { - val f = File(location) - if (!f.exists()) { - logger.error("Could not find the private keys file at: $location") + val content = try { + File(location).readText() + } catch (e: FileNotFoundException) { return null - } - val fileContent = try { - f.readText() // read from disk. } catch (e: Exception) { - logger.error("Could not read private keys from disk, detail: ${e.message}") - return null + throw Exception("Could not read private keys from disk", e) } return try { - myJson.decodeFromString(fileContent) // Parse into JSON. + myJson.decodeFromString(content) } catch (e: Exception) { - logger.error(e.message) - @OptIn(InternalAPI::class) // enables message below. - logger.error(e.rootCause?.message) // actual useful message mentioning failing fields - return null + throw Exception("Could not decode private keys", e) } } diff --git a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt index 615e5a2b..b700abb2 100644 --- a/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt +++ b/nexus/src/main/kotlin/tech/libeufin/nexus/ebics/EbicsCommon.kt @@ -336,14 +336,14 @@ suspend fun doEbicsDownload( val initResp = postEbics(client, cfg, bankKeys, reqXml, isEbics3) logger.debug("Download init phase done. EBICS- and bank-technical codes are: ${initResp.technicalReturnCode}, ${initResp.bankReturnCode}") if (initResp.technicalReturnCode != EbicsReturnCode.EBICS_OK) { - throw Error("Download init phase has EBICS-technical error: ${initResp.technicalReturnCode}") + throw Exception("Download init phase has EBICS-technical error: ${initResp.technicalReturnCode}") } if (initResp.bankReturnCode == EbicsReturnCode.EBICS_NO_DOWNLOAD_DATA_AVAILABLE && tolerateEmptyResult) { logger.info("Download content is empty") return ByteArray(0) } if (initResp.bankReturnCode != EbicsReturnCode.EBICS_OK) { - throw Error("Download init phase has bank-technical error: ${initResp.bankReturnCode}") + throw Exception("Download init phase has bank-technical error: ${initResp.bankReturnCode}") } val tId = initResp.transactionID ?: throw EbicsSideException( @@ -353,7 +353,7 @@ suspend fun doEbicsDownload( logger.debug("EBICS download transaction passed the init phase, got ID: $tId") val howManySegments = initResp.numSegments if (howManySegments == null) { - throw Error("Init response lacks the quantity of segments, failing.") + throw Exception("Init response lacks the quantity of segments, failing.") } val ebicsChunks = mutableListOf<String>() // Getting the chunk(s) @@ -385,7 +385,7 @@ suspend fun doEbicsDownload( } val chunk = transResp.orderDataEncChunk if (chunk == null) { - throw Error("EBICS transfer phase lacks chunk #$x, failing.") + throw Exception("EBICS transfer phase lacks chunk #$x, failing.") } ebicsChunks.add(chunk) } diff --git a/nexus/src/test/kotlin/CliTest.kt b/nexus/src/test/kotlin/CliTest.kt new file mode 100644 index 00000000..e6386e21 --- /dev/null +++ b/nexus/src/test/kotlin/CliTest.kt @@ -0,0 +1,98 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2023 Stanisci and Dold. + + * 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.nexus.* +import com.github.ajalt.clikt.core.* +import com.github.ajalt.clikt.testing.* +import kotlin.test.* +import java.io.* +import java.nio.file.* +import kotlin.io.path.* +import tech.libeufin.util.* + +val nexusCmd = LibeufinNexusCommand() + +fun CliktCommand.testErr(cmd: String, msg: String) { + val prevOut = System.err + val tmpOut = ByteArrayOutputStream() + System.setErr(PrintStream(tmpOut)) + val result = test(cmd) + System.setErr(prevOut) + val tmpStr = tmpOut.toString(Charsets.UTF_8) + println(tmpStr) + assertEquals(1, result.statusCode, "'$cmd' should have failed") + val line = tmpStr.substringAfterLast(" - ").trimEnd('\n') + println(line) + assertEquals(msg, line) +} + +class CliTest { + /** Test error format related to the keying process */ + @Test + fun keys() { + val cmds = listOf("ebics-submit", "ebics-fetch") + val allCmds = listOf("ebics-submit", "ebics-fetch", "ebics-setup") + val conf = "conf/test.conf" + val cfg = loadConfig(conf) + val clientKeysPath = Path(cfg.requireString("nexus-ebics", "client_private_keys_file")) + val bankKeysPath = Path(cfg.requireString("nexus-ebics", "bank_public_keys_file")) + clientKeysPath.parent?.createDirectories() + bankKeysPath.parent?.createDirectories() + + // Missing client keys + clientKeysPath.deleteIfExists() + for (cmd in cmds) { + nexusCmd.testErr("$cmd -c $conf", "Cannot operate without client keys. Missing '$clientKeysPath' file. Run 'libeufin-nexus ebics-setup' first") + } + // Bad client json + clientKeysPath.writeText("CORRUPTION", Charsets.UTF_8) + for (cmd in allCmds) { + nexusCmd.testErr("$cmd -c $conf", "Could not decode private keys: Expected start of the object '{', but had 'EOF' instead at path: $\nJSON input: CORRUPTION") + } + // Unfinished client + syncJsonToDisk(generateNewKeys(), clientKeysPath.toString()) + for (cmd in cmds) { + nexusCmd.testErr("$cmd -c $conf", "Cannot operate with unsubmitted client keys, run 'libeufin-nexus ebics-setup' first") + } + + // Missing bank keys + syncJsonToDisk(generateNewKeys().apply { + submitted_hia = true + submitted_ini = true + }, clientKeysPath.toString()) + bankKeysPath.deleteIfExists() + for (cmd in cmds) { + nexusCmd.testErr("$cmd -c $conf", "Cannot operate without bank keys. Missing '$bankKeysPath' file. run 'libeufin-nexus ebics-setup' first") + } + // Bad bank json + bankKeysPath.writeText("CORRUPTION", Charsets.UTF_8) + for (cmd in allCmds) { + nexusCmd.testErr("$cmd -c $conf", "Could not decode bank keys: Expected start of the object '{', but had 'EOF' instead at path: $\nJSON input: CORRUPTION") + } + // Unfinished bank + syncJsonToDisk(BankPublicKeysFile( + bank_authentication_public_key = CryptoUtil.generateRsaKeyPair(2048).public, + bank_encryption_public_key = CryptoUtil.generateRsaKeyPair(2048).public, + accepted = false + ), bankKeysPath.toString()) + for (cmd in cmds) { + nexusCmd.testErr("$cmd -c $conf", "Cannot operate with unaccepted bank keys, run 'libeufin-nexus ebics-setup' until accepting the bank keys") + } + } +}
\ No newline at end of file diff --git a/nexus/src/test/kotlin/Keys.kt b/nexus/src/test/kotlin/Keys.kt index d2894c04..37c095fd 100644 --- a/nexus/src/test/kotlin/Keys.kt +++ b/nexus/src/test/kotlin/Keys.kt @@ -23,7 +23,7 @@ class PublicKeys { bank_encryption_public_key = CryptoUtil.generateRsaKeyPair(2028).public ) // storing them on disk. - assertTrue(syncJsonToDisk(fileContent, "/tmp/nexus-tests-bank-keys.json")) + syncJsonToDisk(fileContent, "/tmp/nexus-tests-bank-keys.json") // loading them and check that values are the same. val fromDisk = loadBankKeys("/tmp/nexus-tests-bank-keys.json") assertNotNull(fromDisk) @@ -50,16 +50,12 @@ class PrivateKeys { fun createWrongPermissions() { f.writeText("won't be overridden") f.setReadOnly() - assertFalse(syncJsonToDisk(clientKeys, f.path)) + try { + syncJsonToDisk(clientKeys, f.path) + throw Exception("Should have failed") + } catch (e: Exception) { } } - // Testing keys file creation. - @Test - fun creation() { - assertFalse(f.exists()) - maybeCreatePrivateKeysFile(f.path) // file doesn't exist, this must create. - j.decodeFromString<ClientPrivateKeysFile>(f.readText()) // reading and validating disk content. - } /** * Tests whether loading keys from disk yields the same * values that were stored to the file. @@ -67,7 +63,7 @@ class PrivateKeys { @Test fun load() { assertFalse(f.exists()) - assertTrue(syncJsonToDisk(clientKeys, f.path)) // Artificially storing this to the file. + syncJsonToDisk(clientKeys, f.path) // Artificially storing this to the file. val fromDisk = loadPrivateKeysFromDisk(f.path) // loading it via the tested routine. assertNotNull(fromDisk) // Checking the values from disk match the initial object. diff --git a/nexus/src/test/kotlin/PostFinance.kt b/nexus/src/test/kotlin/PostFinance.kt deleted file mode 100644 index 860e8ef0..00000000 --- a/nexus/src/test/kotlin/PostFinance.kt +++ /dev/null @@ -1,219 +0,0 @@ -import io.ktor.client.* -import kotlinx.coroutines.runBlocking -import org.junit.Ignore -import org.junit.Test -import tech.libeufin.nexus.* -import tech.libeufin.nexus.ebics.* -import tech.libeufin.util.ebics_h005.Ebics3Request -import tech.libeufin.util.parsePayto -import java.io.File -import java.time.Instant -import java.time.temporal.ChronoUnit -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -// Tests only manual, that's why they are @Ignore - -private fun prep(): EbicsSetupConfig { - val handle = TalerConfig(NEXUS_CONFIG_SOURCE) - val ebicsUserId = File("/tmp/pofi-ebics-user-id.txt").readText() - val ebicsPartnerId = File("/tmp/pofi-ebics-partner-id.txt").readText() - handle.loadFromString(getPofiConfig(ebicsUserId, ebicsPartnerId)) - return EbicsSetupConfig(handle) -} - -@Ignore -class Iso20022 { - - private val yesterday: Instant = Instant.now().minus(1, ChronoUnit.DAYS) - - @Test // asks a pain.002, links with pain.001's MsgId - fun getAck() { - download(prepAckRequest3(startDate = yesterday) - ).unzipForEach { name, content -> - println(name) - println(content) - } - } - - /** - * With the "mit Detailavisierung" option, each entry has an - * AcctSvcrRef & wire transfer subject. - */ - @Test - fun getStatement() { - val inflatedBytes = download(prepStatementRequest3()) - inflatedBytes.unzipForEach { name, content -> - println(name) - println(content) - } - } - - @Test - fun getNotification() { - val inflatedBytes = download( - prepNotificationRequest3( - // startDate = yesterday, - isAppendix = true - ) - ) - inflatedBytes.unzipForEach { name, content -> - println(name) - println(content) - } - } - - /** - * Never shows the subject. - */ - @Test - fun getReport() { - download(prepReportRequest3(yesterday)).unzipForEach { name, content -> - println(name) - println(content) - } - } - - @Test - fun simulateIncoming() { - val cfg = prep() - val orderService: Ebics3Request.OrderDetails.Service = Ebics3Request.OrderDetails.Service().apply { - serviceName = "OTH" - scope = "BIL" - messageName = Ebics3Request.OrderDetails.Service.MessageName().apply { - value = "csv" - } - serviceOption = "CH002LMF" - } - val instruction = """ - Product;Channel;Account;Currency;Amount;Reference;Name;Street;Number;Postcode;City;Country;DebtorAddressLine;DebtorAddressLine;DebtorAccount;ReferenceType;UltimateDebtorName;UltimateDebtorStreet;UltimateDebtorNumber;UltimateDebtorPostcode;UltimateDebtorTownName;UltimateDebtorCountry;UltimateDebtorAddressLine;UltimateDebtorAddressLine;RemittanceInformationText - QRR;PO;CH9789144829733648596;CHF;1;;D009;Musterstrasse;1;1111;Musterstadt;CH;;;;NON;D009;Musterstrasse;1;1111;Musterstadt;CH;;;Taler-Demo - """.trimIndent() - - runBlocking { - try { - doEbicsUpload( - HttpClient(), - cfg, - loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)!!, - loadBankKeys(cfg.bankPublicKeysFilename)!!, - orderService, - instruction.toByteArray(Charsets.UTF_8) - ) - } - catch (e: EbicsUploadException) { - logger.error(e.message) - logger.error("bank EC: ${e.bankErrorCode}, EBICS EC: ${e.ebicsErrorCode}") - } - } - } - - fun download(req: Ebics3Request.OrderDetails.BTOrderParams): ByteArray { - val cfg = prep() - val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename)!! - val myKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)!! - val initXml = createEbics3DownloadInitialization( - cfg, - bankKeys, - myKeys, - orderParams = req - ) - return runBlocking { - doEbicsDownload( - HttpClient(), - cfg, - myKeys, - bankKeys, - initXml, - isEbics3 = true, - tolerateEmptyResult = true - ) - } - } - - @Test - fun sendPayment() { - val cfg = prep() - val xml = createPain001( - "random", - Instant.now(), - cfg.myIbanAccount, - TalerAmount(4, 0, "CHF"), - "Test reimbursement, part 2", - parsePayto("payto://iban/CH9300762011623852957?receiver-name=NotGiven")!! - ) - runBlocking { - // Not asserting, as it throws in case of errors. - submitPain001( - xml, - cfg, - loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename)!!, - loadBankKeys(cfg.bankPublicKeysFilename)!!, - HttpClient() - ) - } - } -} - -@Ignore -class PostFinance { - // Tests sending client keys to the PostFinance test platform. - @Test - fun postClientKeys() { - val cfg = prep() - runBlocking { - val httpClient = HttpClient() - assertTrue(doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, KeysOrderType.INI)) - assertTrue(doKeysRequestAndUpdateState(cfg, clientKeys, httpClient, KeysOrderType.HIA)) - } - } - - // Tests getting the PostFinance keys from their test platform. - @Test - fun getBankKeys() { - val cfg = prep() - val keys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) - assertNotNull(keys) - assertTrue(keys.submitted_ini) - assertTrue(keys.submitted_hia) - runBlocking { - assertTrue( - doKeysRequestAndUpdateState( - cfg, - keys, - HttpClient(), - KeysOrderType.HPB - )) - } - } - - // Arbitrary download request for manual tests. - @Test - fun customDownload() { - val cfg = prep() - val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) - val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename) - runBlocking { - val bytes = doEbicsCustomDownload( - messageType = "HTD", - cfg = cfg, - bankKeys = bankKeys!!, - clientKeys = clientKeys!!, - client = HttpClient() - ) - println(bytes.toString()) - } - } - - // Tests the HTD message type. - @Test - fun fetchAccounts() { - val cfg = prep() - val clientKeys = loadPrivateKeysFromDisk(cfg.clientPrivateKeysFilename) - assertNotNull(clientKeys) - val bankKeys = loadBankKeys(cfg.bankPublicKeysFilename) - assertNotNull(bankKeys) - val htd = runBlocking { fetchBankAccounts(cfg, clientKeys, bankKeys, HttpClient()) } - println(htd) - } -}
\ No newline at end of file diff --git a/util/src/main/kotlin/Cli.kt b/util/src/main/kotlin/Cli.kt index 25f99340..93a15a21 100644 --- a/util/src/main/kotlin/Cli.kt +++ b/util/src/main/kotlin/Cli.kt @@ -36,7 +36,14 @@ fun cliCmd(logger: Logger, lambda: () -> Unit) { try { lambda() } catch (e: Throwable) { - logger.error(e.message) + var msg = StringBuilder(e.message) + var cause = e.cause; + while (cause != null) { + msg.append(": ") + msg.append(cause.message) + cause = cause.cause + } + logger.error(msg.toString()) throw ProgramResult(1) } } @@ -81,13 +88,13 @@ private class CliConfigGet(private val configSource: ConfigSource) : CliktComman if (isPath) { val res = config.lookupPath(sectionName, optionName) if (res == null) { - throw Error("value not found in config") + throw Exception("value not found in config") } println(res) } else { val res = config.lookupString(sectionName, optionName) if (res == null) { - throw Error("value not found in config") + throw Exception("value not found in config") } println(res) } diff --git a/util/src/main/kotlin/DB.kt b/util/src/main/kotlin/DB.kt index 13fe1b2b..0fa26c9a 100644 --- a/util/src/main/kotlin/DB.kt +++ b/util/src/main/kotlin/DB.kt @@ -235,7 +235,7 @@ fun initializeDatabaseTables(conn: PgConnection, cfg: DatabaseConfig, sqlFilePre val patchName = "$sqlFilePrefix-$numStr" checkStmt.setString(1, patchName) - val patchCount = checkStmt.oneOrNull { it.getInt(1) } ?: throw Error("unable to query patches"); + val patchCount = checkStmt.oneOrNull { it.getInt(1) } ?: throw Exception("unable to query patches"); if (patchCount >= 1) { logger.info("patch $patchName already applied") continue |