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 }