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 }