libeufin

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

ConfigTest.kt (12621B)


      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(1, 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         configPath.writeText("\n@inline-matching@*.conf")
     83         testErr("Recursion limit in config inlining at '$secondPath:1'")
     84         configPath.writeText("\n\n@inline@test-conf.conf")
     85         testErr("Recursion limit in config inlining at '$configPath:2'")
     86     }
     87     
     88     fun checkErr(msg: String, block: () -> Unit) {
     89         val exception = assertFailsWith<TalerConfigError>(null, block)
     90         println(exception.message)
     91         assertEquals(msg, exception.message)
     92     }
     93 
     94     @Test
     95     fun parsing() {
     96         checkErr("expected section header at 'mem:1'") {
     97             ConfigSource("test", "test", "test").fromMem(
     98                 """
     99                 key=value
    100                 """
    101             )
    102         }
    103 
    104         checkErr("expected section header, option assignment or directive at 'mem:2'") {
    105             ConfigSource("test", "test", "test").fromMem(
    106                 """
    107                 [section]
    108                 bad-line
    109                 """
    110             )
    111         }
    112 
    113         ConfigSource("test", "test", "test").fromMem(
    114             """
    115 
    116             [section-a]
    117 
    118             bar = baz
    119 
    120             [section-b]
    121 
    122             first_value = 1
    123             second_value = "test"
    124 
    125             """.trimIndent()
    126         ).let { conf ->
    127             // Missing section
    128             checkErr("Missing string option 'value' in section 'unknown'") {
    129                 conf.section("unknown").string("value").require()
    130             }
    131 
    132             // Missing value
    133             checkErr("Missing string option 'value' in section 'section-a'") {
    134                 conf.section("section-a").string("value").require()
    135             }
    136         }
    137     }
    138 
    139     fun <T> testConfigValue(
    140         type: String,
    141         lambda: TalerConfigSection.(String) -> TalerConfigOption<T>,
    142         wellformed: List<Pair<List<String>, T>>,
    143         malformed: List<Pair<List<String>, (String) -> String>>,
    144         conf: String = ""
    145     ) {
    146         fun conf(content: String) = ConfigSource("test", "test", "test").fromMem("$conf\n$content")
    147 
    148         // Check missing msg
    149         val conf = conf("")
    150         checkErr("Missing $type option 'value' in section 'section'") {
    151             conf.section("section").lambda("value").require()
    152         }
    153 
    154         // Check wellformed options are properly parsed
    155         for ((raws, expected) in wellformed) {
    156             for (raw in raws) {
    157                 val conf = conf("[section]\nvalue=$raw")
    158                 assertEquals(expected, conf.section("section").lambda("value").require())
    159             }
    160         }
    161 
    162         // Check malformed options have proper error message
    163         for ((raws, errorFmt) in malformed) {
    164             for (raw in raws) {
    165                 val conf = conf("[section]\nvalue=$raw")
    166                 checkErr("Expected $type option 'value' in section 'section': ${errorFmt(raw)}") {
    167                     conf.section("section").lambda("value").require()
    168                 }
    169             }
    170         }
    171     }
    172     fun <T> testConfigValue(
    173         type: String,
    174         lambda: TalerConfigSection.(String) -> TalerConfigOption<T>,
    175         wellformed: List<Pair<List<String>, T>>,
    176         malformed: Pair<List<String>, (String) -> String>
    177     ) = testConfigValue(type, lambda, wellformed, listOf(malformed))
    178 
    179     @Test
    180     fun string() = testConfigValue(
    181         "string", TalerConfigSection::string, listOf(
    182             listOf("1", "\"1\"") to "1",
    183             listOf("test", "\"test\"") to "test",
    184             listOf("\"") to "\"",
    185         ), listOf()
    186     )
    187 
    188     @Test
    189     fun number() = testConfigValue(
    190         "number", TalerConfigSection::number, listOf(
    191             listOf("1") to 1,
    192             listOf("42") to 42
    193         ), listOf("true", "YES") to { "'$it' not a valid number" }
    194     )
    195 
    196     @Test
    197     fun boolean() = testConfigValue(
    198         "boolean", TalerConfigSection::boolean, listOf(
    199             listOf("yes", "YES", "Yes") to true,
    200             listOf("no", "NO", "No") to false
    201         ), listOf("true", "1") to { "expected 'YES' or 'NO' got '$it'" }
    202     )
    203 
    204     @Test
    205     fun path() = testConfigValue(
    206         "path", TalerConfigSection::path,
    207         listOf(
    208             listOf("path") to Path("path"),
    209             listOf("foo/\$DATADIR/bar", "foo/\${DATADIR}/bar") to Path("foo/mydir/bar"),
    210             listOf("foo/\$DATADIR\$DATADIR/bar") to Path("foo/mydirmydir/bar"),
    211             listOf("foo/pre_\$DATADIR/bar", "foo/pre_\${DATADIR}/bar") to Path("foo/pre_mydir/bar"),
    212             listOf("foo/\${DATADIR}_next/bar", "foo/\${UNKNOWN:-\$DATADIR}_next/bar") to Path("foo/mydir_next/bar"),
    213             listOf("foo/\${UNKNOWN:-default}_next/bar", "foo/\${UNKNOWN:-\${UNKNOWN:-default}}_next/bar") to Path("foo/default_next/bar"),
    214             listOf("foo/\${UNKNOWN:-pre_\${UNKNOWN:-default}_next}_next/bar") to Path("foo/pre_default_next_next/bar"),
    215         ),
    216         listOf(
    217             listOf("foo/\${A/bar") to { "bad substitution '\${A/bar'" },
    218             listOf("foo/\${A:-pre_\${B}/bar") to { "unbalanced variable expression 'pre_\${B}/bar'" },
    219             listOf("foo/\${A:-\${B\${C}/bar") to { "unbalanced variable expression '\${B\${C}/bar'" },
    220             listOf("foo/\$UNKNOWN/bar", "foo/\${UNKNOWN}/bar") to { "unbound variable 'UNKNOWN'" },
    221             listOf("foo/\$RECURSIVE/bar") to { "recursion limit in path substitution exceeded for '\$RECURSIVE'" }
    222         ),
    223         "[PATHS]\nDATADIR=mydir\nRECURSIVE=\$RECURSIVE"
    224     )
    225 
    226     @Test
    227     fun duration() = testConfigValue(
    228         "temporal", TalerConfigSection::duration,
    229         listOf(
    230             listOf("1s", "1 s") to Duration.ofSeconds(1),
    231             listOf("10m", "10 m") to Duration.ofMinutes(10),
    232             listOf("1h") to Duration.ofHours(1),
    233             listOf("1h10m12s", "1h 10m 12s", "1 h 10 m 12 s", "1h10'12\"") to
    234                 Duration.ofHours(1).plus(Duration.ofMinutes(10)).plus(Duration.ofSeconds(12)),
    235         ),
    236         listOf(
    237             listOf("test", "42") to { "'$it' not a valid temporal" },
    238             listOf("42t") to { "'t' not a valid temporal unit" },
    239             listOf("9223372036854775808s") to { "'9223372036854775808' not a valid temporal amount" },
    240         )
    241     )
    242 
    243     @Test
    244     fun date() = testConfigValue(
    245         "date", TalerConfigSection::date,
    246         listOf(
    247             listOf("2024-12-12") to dateToInstant("2024-12-12"),
    248         ),
    249         listOf(
    250             listOf("test", "42") to { "'$it' not a valid date" },
    251             listOf("2024-12-32") to { "'$it' not a valid date: Invalid value for DayOfMonth (valid values 1 - 28/31): 32" },
    252             listOf("2024-42-12") to { "'$it' not a valid date: Invalid value for MonthOfYear (valid values 1 - 12): 42" },
    253             listOf("2024-12-32s") to { "'$it' not a valid date at index 10" },
    254         )
    255     )
    256 
    257     @Test
    258     fun jsonMap() = testConfigValue(
    259         "json key/value map", TalerConfigSection::jsonMap,
    260         listOf(
    261             listOf("{\"a\": \"12\", \"b\": \"test\"}") to mapOf("a" to "12", "b" to "test"),
    262         ),
    263         listOf("test", "12", "{\"a\": 12}", "{\"a\": \"12\",") to { "'$it' is malformed" }
    264     )
    265 
    266     @Test
    267     fun amount() = testConfigValue(
    268         "amount", { amount(it, "KUDOS") },
    269         listOf(
    270             listOf("KUDOS:12", "KUDOS:12.0", "KUDOS:012.0") to TalerAmount("KUDOS:12"),
    271         ),
    272         listOf(
    273             listOf("test", "42", "KUDOS:0.3ABC") to { "'$it' is malformed: Invalid amount format" },
    274             listOf("KUDOS:999999999999999999") to { "'$it' is malformed: Value specified in amount is too large" },
    275             listOf("EUR:12") to { "expected currency KUDOS got EUR" },
    276         )
    277     )
    278 
    279     @Test
    280     fun map() = testConfigValue(
    281         "map", { map(it, "map", mapOf("one" to 1, "two" to 2, "three" to 3)) },
    282         listOf(
    283             listOf("one") to 1,
    284             listOf("two") to 2,
    285             listOf("three") to 3,
    286         ),
    287         listOf(
    288             listOf("test", "42") to { "expected 'one', 'two' or 'three' got '$it'" },
    289         )
    290     )
    291 
    292     @Test
    293     fun mapLambda() = testConfigValue(
    294         "lambda",
    295         { 
    296             map(it, "lambda", mapOf("ok" to 1, "fail" to { throw Exception("Never executed") }))
    297         },
    298         listOf(
    299             listOf("ok") to 1,
    300         ),
    301         listOf(
    302             listOf("test", "42") to { "expected 'ok' or 'fail' got '$it'" }
    303         )
    304     )
    305 
    306     @Test
    307     fun jdbcParsing() {
    308         val user = currentUser()
    309         assertFails { jdbcFromPg("test") }
    310         assertEquals("jdbc:test", jdbcFromPg("jdbc:test"))
    311         assertEquals("jdbc:postgresql://localhost/?user=$user&socketFactory=org.newsclub.net.unix.AFUNIXSocketFactory\$FactoryArg&socketFactoryArg=/var/run/postgresql/.s.PGSQL.5432", jdbcFromPg("postgresql:///"))
    312         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"))
    313         withEnvironmentVariable("PGPORT", "1234").execute {
    314             assertEquals("jdbc:postgresql://localhost/?user=$user&socketFactory=org.newsclub.net.unix.AFUNIXSocketFactory\$FactoryArg&socketFactoryArg=/var/run/postgresql/.s.PGSQL.1234", jdbcFromPg("postgresql:///"))
    315         }
    316         withEnvironmentVariable("PGPORT", "1234").and("PGHOST", "/tmp").execute {
    317             assertEquals("jdbc:postgresql://localhost/?user=$user&socketFactory=org.newsclub.net.unix.AFUNIXSocketFactory\$FactoryArg&socketFactoryArg=/tmp/.s.PGSQL.1234", jdbcFromPg("postgresql:///"))
    318         }
    319     }
    320 }