libeufin

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

ConfigTest.kt (12766B)


      1 /*
      2  * This file is part of LibEuFin.
      3  * Copyright (C) 2023-2025 Taler Systems S.A.
      4  *
      5  * LibEuFin is free software; you can redistribute it and/or modify
      6  * it under the terms of the GNU Affero General Public License as
      7  * published by the Free Software Foundation; either version 3, or
      8  * (at your option) any later version.
      9  *
     10  * LibEuFin is distributed in the hope that it will be useful, but
     11  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12  * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13  * Public License for more details.
     14  *
     15  * You should have received a copy of the GNU Affero General Public
     16  * License along with LibEuFin; see the file COPYING.  If not, see
     17  * <http://www.gnu.org/licenses/>
     18  */
     19 
     20 import com.github.ajalt.clikt.testing.test
     21 import org.junit.Test
     22 import tech.libeufin.common.*
     23 import tech.libeufin.common.db.currentUser
     24 import tech.libeufin.common.db.jdbcFromPg
     25 import uk.org.webcompere.systemstubs.SystemStubs.withEnvironmentVariable
     26 import java.io.ByteArrayOutputStream
     27 import java.io.PrintStream
     28 import java.time.Duration
     29 import kotlin.io.path.*
     30 import kotlin.test.assertEquals
     31 import kotlin.test.assertFails
     32 import kotlin.test.assertFailsWith
     33 
     34 class ConfigTest {
     35     @Test
     36     fun cli() {
     37         val cmd = CliConfigCmd(ConfigSource("test", "test", "test"))
     38         val configPath = Path("tmp/test-conf.conf")
     39         val secondPath = Path("tmp/test-second-conf.conf")
     40 
     41         fun testErr(msg: String) {
     42             val prevErr = System.err
     43             val tmpErr = ByteArrayOutputStream()
     44             System.setErr(PrintStream(tmpErr))
     45             val result = cmd.test("dump -c $configPath")
     46             System.setErr(prevErr)
     47             val lastLog = tmpErr.asUtf8().substringAfterLast(" - ").trimEnd('\n')
     48             assertEquals(6, result.statusCode, lastLog)
     49             assertEquals(msg, lastLog, lastLog)
     50         }
     51 
     52         configPath.deleteIfExists()
     53         testErr("Could not read config at '$configPath': no such file")
     54 
     55         configPath.createParentDirectories()
     56         configPath.createFile()
     57         configPath.toFile().setReadable(false)
     58         if (!configPath.isReadable()) { // Skip if root
     59             testErr("Could not read config at '$configPath': permission denied")
     60         }
     61 
     62         configPath.toFile().setReadable(true)
     63         configPath.writeText("@inline@test-second-conf.conf")
     64         secondPath.deleteIfExists()
     65         testErr("Could not read config at '$secondPath': no such file")
     66 
     67         secondPath.createFile()
     68         secondPath.toFile().setReadable(false)
     69         if (!secondPath.isReadable()) { // Skip if root
     70             testErr("Could not read config at '$secondPath': permission denied")
     71         }
     72         
     73         configPath.writeText("@inline-matching@[*")
     74         testErr("Malformed glob regex at '$configPath:0': Missing '] near index 1\n[*\n ^")
     75 
     76         configPath.writeText("@inline-matching@*second-conf.conf")
     77         if (!secondPath.isReadable()) { // Skip if root
     78             testErr("Could not read config at '$secondPath': permission denied")
     79         }
     80 
     81         secondPath.toFile().setReadable(true)
     82         secondPath.writeText("\n@inline@test-second-conf.conf")
     83         configPath.writeText("\n@inline-matching@*second-conf.conf")
     84         testErr("Recursion limit in config inlining at '$secondPath:1'")
     85         configPath.writeText("\n\n@inline@test-conf.conf")
     86         testErr("Recursion limit in config inlining at '$configPath:2'")
     87     }
     88     
     89     fun checkErr(msg: String, block: () -> Unit) {
     90         val exception = assertFailsWith<TalerConfigError>(null, block)
     91         println(exception.message)
     92         assertEquals(msg, exception.message)
     93     }
     94 
     95     @Test
     96     fun parsing() {
     97         checkErr("expected section header at 'mem:1'") {
     98             ConfigSource("test", "test", "test").fromMem(
     99                 """
    100                 key=value
    101                 """
    102             )
    103         }
    104 
    105         checkErr("expected section header, option assignment or directive at 'mem:2'") {
    106             ConfigSource("test", "test", "test").fromMem(
    107                 """
    108                 [section]
    109                 bad-line
    110                 """
    111             )
    112         }
    113 
    114         ConfigSource("test", "test", "test").fromMem(
    115             """
    116 
    117             [section-a]
    118 
    119             bar = baz
    120 
    121             [section-b]
    122 
    123             first_value = 1
    124             second_value = "test"
    125 
    126             """.trimIndent()
    127         ).let { conf ->
    128             // Missing section
    129             checkErr("Missing string option 'value' in section 'unknown'") {
    130                 conf.section("unknown").string("value").require()
    131             }
    132 
    133             // Missing value
    134             checkErr("Missing string option 'value' in section 'section-a'") {
    135                 conf.section("section-a").string("value").require()
    136             }
    137         }
    138     }
    139 
    140     fun <T> testConfigValue(
    141         type: String,
    142         lambda: TalerConfigSection.(String) -> TalerConfigOption<T>,
    143         wellformed: List<Pair<List<String>, T>>,
    144         malformed: List<Pair<List<String>, (String) -> String>>,
    145         conf: String = ""
    146     ) {
    147         fun conf(content: String) = ConfigSource("test", "test", "test").fromMem("$conf\n$content")
    148 
    149         // Check missing msg
    150         val conf = conf("")
    151         checkErr("Missing $type option 'value' in section 'section'") {
    152             conf.section("section").lambda("value").require()
    153         }
    154 
    155         // Check wellformed options are properly parsed
    156         for ((raws, expected) in wellformed) {
    157             for (raw in raws) {
    158                 val conf = conf("[section]\nvalue=$raw")
    159                 assertEquals(expected, conf.section("section").lambda("value").require())
    160             }
    161         }
    162 
    163         // Check malformed options have proper error message
    164         for ((raws, errorFmt) in malformed) {
    165             for (raw in raws) {
    166                 val conf = conf("[section]\nvalue=$raw")
    167                 checkErr("Expected $type option 'value' in section 'section': ${errorFmt(raw)}") {
    168                     conf.section("section").lambda("value").require()
    169                 }
    170             }
    171         }
    172     }
    173     fun <T> testConfigValue(
    174         type: String,
    175         lambda: TalerConfigSection.(String) -> TalerConfigOption<T>,
    176         wellformed: List<Pair<List<String>, T>>,
    177         malformed: Pair<List<String>, (String) -> String>
    178     ) = testConfigValue(type, lambda, wellformed, listOf(malformed))
    179 
    180     @Test
    181     fun string() = testConfigValue(
    182         "string", TalerConfigSection::string, listOf(
    183             listOf("1", "\"1\"") to "1",
    184             listOf("test", "\"test\"") to "test",
    185             listOf("\"") to "\"",
    186         ), listOf()
    187     )
    188 
    189     @Test
    190     fun number() = testConfigValue(
    191         "number", TalerConfigSection::number, listOf(
    192             listOf("1") to 1,
    193             listOf("42") to 42
    194         ), listOf("true", "YES") to { "'$it' not a valid number" }
    195     )
    196 
    197     @Test
    198     fun boolean() = testConfigValue(
    199         "boolean", TalerConfigSection::boolean, listOf(
    200             listOf("yes", "YES", "Yes") to true,
    201             listOf("no", "NO", "No") to false
    202         ), listOf("true", "1") to { "expected 'YES' or 'NO' got '$it'" }
    203     )
    204 
    205     @Test
    206     fun path() = testConfigValue(
    207         "path", TalerConfigSection::path,
    208         listOf(
    209             listOf("path") to Path("path"),
    210             listOf("foo/\$DATADIR/bar", "foo/\${DATADIR}/bar") to Path("foo/mydir/bar"),
    211             listOf("foo/\$DATADIR\$DATADIR/bar") to Path("foo/mydirmydir/bar"),
    212             listOf("foo/pre_\$DATADIR/bar", "foo/pre_\${DATADIR}/bar") to Path("foo/pre_mydir/bar"),
    213             listOf("foo/\${DATADIR}_next/bar", "foo/\${UNKNOWN:-\$DATADIR}_next/bar") to Path("foo/mydir_next/bar"),
    214             listOf("foo/\${UNKNOWN:-default}_next/bar", "foo/\${UNKNOWN:-\${UNKNOWN:-default}}_next/bar") to Path("foo/default_next/bar"),
    215             listOf("foo/\${UNKNOWN:-pre_\${UNKNOWN:-default}_next}_next/bar") to Path("foo/pre_default_next_next/bar"),
    216         ),
    217         listOf(
    218             listOf("foo/\${A/bar") to { "bad substitution '\${A/bar'" },
    219             listOf("foo/\${A:-pre_\${B}/bar") to { "unbalanced variable expression 'pre_\${B}/bar'" },
    220             listOf("foo/\${A:-\${B\${C}/bar") to { "unbalanced variable expression '\${B\${C}/bar'" },
    221             listOf("foo/\$UNKNOWN/bar", "foo/\${UNKNOWN}/bar") to { "unbound variable 'UNKNOWN'" },
    222             listOf("foo/\$RECURSIVE/bar") to { "recursion limit in path substitution exceeded for '\$RECURSIVE'" }
    223         ),
    224         "[PATHS]\nDATADIR=mydir\nRECURSIVE=\$RECURSIVE"
    225     )
    226 
    227     @Test
    228     fun duration() = testConfigValue(
    229         "temporal", TalerConfigSection::duration,
    230         listOf(
    231             listOf("1s", "1 s") to Duration.ofSeconds(1),
    232             listOf("10m", "10 m") to Duration.ofMinutes(10),
    233             listOf("1h") to Duration.ofHours(1),
    234             listOf("1h10m12s", "1h 10m 12s", "1 h 10 m 12 s", "1h10'12\"") to
    235                 Duration.ofHours(1).plus(Duration.ofMinutes(10)).plus(Duration.ofSeconds(12)),
    236         ),
    237         listOf(
    238             listOf("test", "42") to { "'$it' not a valid temporal" },
    239             listOf("42t") to { "'t' not a valid temporal unit" },
    240             listOf("9223372036854775808s") to { "'9223372036854775808' not a valid temporal amount" },
    241         )
    242     )
    243 
    244     @Test
    245     fun date() = testConfigValue(
    246         "date", TalerConfigSection::date,
    247         listOf(
    248             listOf("2024-12-12") to dateToInstant("2024-12-12"),
    249         ),
    250         listOf(
    251             listOf("test", "42") to { "'$it' not a valid date" },
    252             listOf("2024-12-32") to { "'$it' not a valid date: Invalid value for DayOfMonth (valid values 1 - 28/31): 32" },
    253             listOf("2024-42-12") to { "'$it' not a valid date: Invalid value for MonthOfYear (valid values 1 - 12): 42" },
    254             listOf("2024-12-32s") to { "'$it' not a valid date at index 10" },
    255         )
    256     )
    257 
    258     @Test
    259     fun jsonMap() = testConfigValue(
    260         "json key/value map", TalerConfigSection::jsonMap,
    261         listOf(
    262             listOf("{\"a\": \"12\", \"b\": \"test\"}") to mapOf("a" to "12", "b" to "test"),
    263         ),
    264         listOf("test", "12", "{\"a\": 12}", "{\"a\": \"12\",") to { "'$it' is malformed" }
    265     )
    266 
    267     @Test
    268     fun amount() = testConfigValue(
    269         "amount", { amount(it, "KUDOS") },
    270         listOf(
    271             listOf("KUDOS:12", "KUDOS:12.0", "KUDOS:012.0") to TalerAmount("KUDOS:12"),
    272         ),
    273         listOf(
    274             listOf("test", "42", "KUDOS:0.3ABC") to { "'$it' is malformed: Invalid amount format" },
    275             listOf("KUDOS:999999999999999999") to { "'$it' is malformed: Value specified in amount is too large" },
    276             listOf("EUR:12") to { "expected currency KUDOS got EUR" },
    277         )
    278     )
    279 
    280     @Test
    281     fun map() = testConfigValue(
    282         "map", { map(it, "map", mapOf("one" to 1, "two" to 2, "three" to 3)) },
    283         listOf(
    284             listOf("one") to 1,
    285             listOf("two") to 2,
    286             listOf("three") to 3,
    287         ),
    288         listOf(
    289             listOf("test", "42") to { "expected 'one', 'two' or 'three' got '$it'" },
    290         )
    291     )
    292 
    293     @Test
    294     fun mapLambda() = testConfigValue(
    295         "lambda",
    296         { 
    297             map(it, "lambda", mapOf("ok" to 1, "fail" to { throw Exception("Never executed") }))
    298         },
    299         listOf(
    300             listOf("ok") to 1,
    301         ),
    302         listOf(
    303             listOf("test", "42") to { "expected 'ok' or 'fail' got '$it'" }
    304         )
    305     )
    306 
    307     @Test
    308     fun jdbcParsing() = withEnvironmentVariable("PGHOST", null).and("PGPORT", null).execute {
    309         val user = currentUser()
    310         assertFails { jdbcFromPg("test") }
    311         assertEquals("jdbc:test", jdbcFromPg("jdbc:test"))
    312         assertEquals("jdbc:postgresql://localhost/?user=$user&socketFactory=org.newsclub.net.unix.AFUNIXSocketFactory\$FactoryArg&socketFactoryArg=/var/run/postgresql/.s.PGSQL.5432", jdbcFromPg("postgresql:///"))
    313         assertEquals("jdbc:postgresql://?host=args%2Dhost&user=arg%23%24User&password=%21%22%23%24%25%26%27%28%29", jdbcFromPg("postgresql://?host=args%2Dhost&user=arg%23%24User&password=%21%22%23%24%25%26%27%28%29"))
    314         withEnvironmentVariable("PGPORT", "1234").execute {
    315             assertEquals("jdbc:postgresql://localhost/?user=$user&socketFactory=org.newsclub.net.unix.AFUNIXSocketFactory\$FactoryArg&socketFactoryArg=/var/run/postgresql/.s.PGSQL.1234", jdbcFromPg("postgresql:///"))
    316         }
    317         withEnvironmentVariable("PGPORT", "1234").and("PGHOST", "/tmp").execute {
    318             assertEquals("jdbc:postgresql://localhost/?user=$user&socketFactory=org.newsclub.net.unix.AFUNIXSocketFactory\$FactoryArg&socketFactoryArg=/tmp/.s.PGSQL.1234", jdbcFromPg("postgresql:///"))
    319         }
    320     }
    321 }