package config import ( "encoding/json" "strings" "testing" "time" "gopkg.in/yaml.v3" ) func TestDurationUnmarshalYAMLAccepted(t *testing.T) { tests := []struct { name string node string want time.Duration }{ {"seconds", `5s`, 5 * time.Second}, {"compound minutes seconds", `1m30s`, 90 * time.Second}, {"milliseconds", `250ms`, 250 * time.Millisecond}, {"hours minutes", `1h30m`, 90 * time.Minute}, {"sub second", `1.5s`, 1500 * time.Millisecond}, {"microseconds", `10us`, 10 * time.Microsecond}, {"zero", `0s`, 0}, {"negative", `-5s`, -5 * time.Second}, {"padded", `" 30s "`, 30 * time.Second}, {"empty string is zero", `""`, 0}, {"null is zero", `null`, 0}, {"tilde is zero", `~`, 0}, {"int seconds", `7`, 7 * time.Second}, {"float seconds", `1.5`, 1500 * time.Millisecond}, {"negative int seconds", `-7`, -7 * time.Second}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var got struct { D Duration `yaml:"d"` } if err := yaml.Unmarshal([]byte("d: "+tc.node), &got); err != nil { t.Fatalf("unmarshal %s: %v", tc.node, err) } if got.D.D() != tc.want { t.Errorf("d: %s = %v, want %v", tc.node, got.D.D(), tc.want) } }) } } func TestDurationUnmarshalYAMLRejected(t *testing.T) { tests := []struct { name string node string mention string }{ {"nonsense", `abc`, `"abc"`}, {"nonsense", `abc`, `"abc"`}, {"space before unit", `"5 s"`, `"5 s"`}, {"unknown unit", `10x`, `"10x"`}, {"overflow", `2562048h`, `"2562048h"`}, {"float overflow", `1e400s`, `1e400s`}, {"numeric seconds overflow", `1e30`, `"1e30"`}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var got struct { D Duration `yaml:"d"` } err := yaml.Unmarshal([]byte("d: "+tc.node), &got) if err == nil { t.Fatalf("unmarshal %s: expected error, got %v", tc.node, got.D.D()) } if !strings.Contains(err.Error(), tc.mention) { t.Errorf("error %q does not mention offending value %s", err, tc.mention) } }) } } func TestDurationUnmarshalYAMLNonScalar(t *testing.T) { for _, node := range []string{`{a: 1}`, `[1,2]`} { var got struct { D Duration `yaml:"d"` } if err := yaml.Unmarshal([]byte("d: "+node), &got); err == nil { t.Errorf("unmarshal d: %s: expected error, got %v", node, got.D.D()) } } } func TestDurationMarshalYAMLRoundTrip(t *testing.T) { for _, d := range []Duration{ Duration(5 * time.Second), Duration(90 * time.Second), Duration(250 * time.Millisecond), Duration(12 * time.Hour), Duration(0), } { out, err := yaml.Marshal(map[string]Duration{"d": d}) if err != nil { t.Fatalf("marshal %v: %v", d.D(), err) } var back struct { D Duration `yaml:"d"` } if err := yaml.Unmarshal(out, &back); err != nil { t.Fatalf("unmarshal %q: %v", out, err) } if back.D != d { t.Errorf("round trip %v via %q: got %v", d.D(), strings.TrimSpace(string(out)), back.D.D()) } } } // Duration has no custom JSON unmarshaller; pin what encoding/json does with // it so a change here is a deliberate one. func TestDurationJSONForm(t *testing.T) { var got struct { D Duration `json:"d"` } if err := json.Unmarshal([]byte(`{"d":5}`), &got); err != nil { t.Fatalf("bare number: %v", err) } if got.D.D() != 5*time.Nanosecond { t.Errorf("bare JSON number = %v (nanoseconds, not seconds)", got.D.D()) } if err := json.Unmarshal([]byte(`{"d":"5s"}`), &got); err == nil { t.Errorf("quoted JSON duration decoded to %v; string form is unsupported", got.D.D()) } out, err := json.Marshal(Duration(5 * time.Second)) if err != nil { t.Fatalf("marshal: %v", err) } if string(out) != "5000000000" { t.Errorf("json.Marshal = %s, want nanoseconds", out) } } // clearEnv removes name for the rest of the test, restoring it afterwards. func clearEnv(t *testing.T, name string) { t.Helper() if old, ok := os.LookupEnv(name); ok { t.Setenv(name, old) // registers the restore-on-cleanup } if err := os.Unsetenv(name); err != nil { t.Fatalf("unset %s: %v", name, err) } } func TestExpandEnvSubstitution(t *testing.T) { t.Setenv("FD_TEST_KEY", "s3cr3t") t.Setenv("FD_TEST_EMPTY", "") t.Setenv("FD_TEST_NESTED", "${FD_TEST_KEY}") clearEnv(t, "FD_TEST_MISSING") tests := []struct { name, in, want string }{ {"double quoted scalar", `api_keys: ["${FD_TEST_KEY}"]` + "\n", `api_keys: ["s3cr3t"]` + "\n"}, {"plain unquoted scalar", "cert_file: /etc/${FD_TEST_KEY}.pem\n", "cert_file: /etc/s3cr3t.pem\n"}, {"single quoted scalar", "password: '${FD_TEST_KEY}'\n", "password: 's3cr3t'\n"}, {"whole value", "password: ${FD_TEST_KEY}\n", "password: s3cr3t\n"}, {"two references on one line", "x: ${FD_TEST_KEY}/${FD_TEST_KEY}\n", "x: s3cr3t/s3cr3t\n"}, {"value is not rescanned", "x: ${FD_TEST_NESTED}\n", "x: ${FD_TEST_KEY}\n"}, {"reference line comment", "# api_keys: ${FD_TEST_KEY}\n", "# api_keys: ${FD_TEST_KEY}\n"}, {"reference trailing comment", "level: info # set ${FD_TEST_KEY} later\n", "level: info # set ${FD_TEST_KEY} later\n"}, {"indented comment", "dns:\n # port ${FD_TEST_KEY}\n max_udp_size: 1232\n", "dns:\n # port ${FD_TEST_KEY}\n max_udp_size: 1232\n"}, {"bare dollar in url", "url: https://x.invalid/?a=$b\n", "url: https://x.invalid/?a=$b\n"}, {"bare dollar blocklist line", " url: https://h.invalid/hosts?$format=raw$other\n", " url: https://h.invalid/hosts?$format=raw$other\n"}, {"bare dollar at end of line", "url: https://x.invalid/$\n", "url: https://x.invalid/$\n"}, {"colon default when unset", "x: ${FD_TEST_MISSING:-fallback}\n", "x: fallback\n"}, {"hyphen default when unset", "x: [${FD_TEST_MISSING-fallback}]\n", "x: [fallback]\n"}, {"colon default when empty", "x: ${FD_TEST_EMPTY:-fallback}\n", "x: fallback\n"}, {"hyphen default when empty stays empty", "x: [${FD_TEST_EMPTY-fallback}]\n", "x: []\n"}, {"empty default", "x: [${FD_TEST_MISSING:-}]\n", "x: []\n"}, {"empty value without default", "x: [${FD_TEST_EMPTY}]\n", "x: []\n"}, {"hash without leading space is data", "x: a#${FD_TEST_KEY}\n", "x: a#s3cr3t\n"}, {"hash inside double quotes is data", "x: \"# ${FD_TEST_KEY}\"\n", "x: \"# s3cr3t\"\n"}, {"hash inside single quotes is data", "x: '# ${FD_TEST_KEY}'\n", "x: '# s3cr3t'\n"}, {"crlf line ending", "x: ${FD_TEST_KEY}\r\ny: 1\r\n", "x: s3cr3t\r\ny: 1\r\n"}, {"crlf comment", "# ${FD_TEST_KEY}\r\n", "# ${FD_TEST_KEY}\r\n"}, {"no trailing newline", "x: ${FD_TEST_KEY}", "x: s3cr3t"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, err := expandEnv([]byte(tc.in)) if err != nil { t.Fatalf("expandEnv(%q): %v", tc.in, err) } if string(got) != tc.want { t.Errorf("expandEnv(%q)\n got %q\nwant %q", tc.in, got, tc.want) } }) } } func TestExpandEnvMultiLine(t *testing.T) { t.Setenv("FD_TEST_KEY", "abc123") clearEnv(t, "FD_TEST_MISSING") in := strings.Join([]string{ "# leading comment ${FD_TEST_KEY}", "api:", " auth:", ` api_keys: ["${FD_TEST_KEY}"] # trailing ${FD_TEST_KEY}`, " username: ${FD_TEST_MISSING:-admin}", " password: ${FD_TEST_MISSING-none}", "", "metrics:", " enabled: false # ${FD_TEST_MISSING} in a comment is fine", "", }, "\n") want := strings.Join([]string{ "# leading comment ${FD_TEST_KEY}", "api:", " auth:", ` api_keys: ["abc123"] # trailing ${FD_TEST_KEY}`, " username: admin", " password: none", "", "metrics:", " enabled: false # ${FD_TEST_MISSING} in a comment is fine", "", }, "\n") got, err := expandEnv([]byte(in)) if err != nil { t.Fatalf("expandEnv: %v", err) } if string(got) != want { t.Errorf("expandEnv multi-line\n got %q\nwant %q", got, want) } } func TestExpandEnvWithoutReferencesIsByteIdentical(t *testing.T) { raw := []byte("# no references here\nurl: https://x.invalid/?a=$b&c=${not_this}\n'# ${FD_TEST_KEY}'\n") clearEnv(t, "FD_TEST_KEY") got, err := expandEnv(raw) if err != nil { t.Fatalf("expandEnv: %v", err) } if !bytes.Equal(got, raw) { t.Errorf("expandEnv changed a document with no references:\n got %q\nwant %q", got, raw) } } func TestExpandEnvErrors(t *testing.T) { clearEnv(t, "FD_TEST_MISSING") t.Setenv("FD_TEST_KEY", "abc123") tests := []struct { name, in string mentions []string }{ {"unset without default", "x: ${FD_TEST_MISSING}\n", []string{"FD_TEST_MISSING", "line 1:"}}, {"unset on third line", "a: 1\nb: 2\nc: ${FD_TEST_MISSING}\n", []string{"FD_TEST_MISSING", "line 3:"}}, {"empty-but-set without default", "x: [${FD_TEST_MISSING}]\n", []string{"FD_TEST_MISSING"}}, {"unterminated reference", "x: ${FD_TEST_KEY\n", []string{"unterminated ${"}}, {"unterminated on later line", "a: 1\nb: ${FD_TEST_KEY\n", []string{"unterminated ${", "line 2:"}}, {"empty name", "x: ${}\n", []string{"empty variable name in ${}"}}, {"empty name with colon default", "x: ${:-fallback}\n", []string{"empty variable name"}}, {"unterminated inside comment is ignored", "# x: ${FD_TEST_KEY\ny: 1\n", nil}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, err := expandEnv([]byte(tc.in)) if len(tc.mentions) == 0 { if err != nil { t.Fatalf("expandEnv(%q) = %v, want success", tc.in, err) } if string(got) != tc.in { t.Errorf("expandEnv(%q) = %q, want it copied verbatim", tc.in, got) } return } if err == nil { t.Fatalf("expandEnv(%q) = %q, want error", tc.in, got) } for _, m := range tc.mentions { if !strings.Contains(err.Error(), m) { t.Errorf("error %q does not mention %q", err, m) } } }) } } func TestLoadSubstitutesEnvInFile(t *testing.T) { t.Setenv("FD_TEST_KEY", "long-random-api-key") t.Setenv("FD_TEST_PW", "p@ss") path := writeTempConfig(t, `api: auth: mode: apikey api_keys: ["${FD_TEST_KEY}"] username: 'admin-${FD_TEST_PW}' password: prefix-${FD_TEST_PW} # the key is documented here: ${FD_TEST_KEY} `) cfg, err := Load(path) if err != nil { t.Fatalf("Load: %v", err) } want := []string{"long-random-api-key"} if len(cfg.API.Auth.APIKeys) != 1 || cfg.API.Auth.APIKeys[0] != want[0] { t.Errorf("api_keys = %q, want %q", cfg.API.Auth.APIKeys, want) } if cfg.API.Auth.Username != "admin-p@ss" { t.Errorf("username = %q, want admin-p@ss", cfg.API.Auth.Username) } if cfg.API.Auth.Password != "prefix-p@ss" { t.Errorf("password = %q, want prefix-p@ss", cfg.API.Auth.Password) } } func TestLoadUnsetEnvIsFatalAndNamesVariable(t *testing.T) { clearEnv(t, "FD_TEST_MISSING") path := writeTempConfig(t, "api:\n auth:\n mode: apikey\n api_keys: [\"${FD_TEST_MISSING}\"]\n") _, err := Load(path) if err == nil { t.Fatal("Load: want error for unset variable") } for _, want := range []string{"FD_TEST_MISSING", path} { if !strings.Contains(err.Error(), want) { t.Errorf("error %q does not mention %q", err, want) } } } func writeTempConfig(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "config.yaml") if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("write config: %v", err) } return path }