The `${VAR}` support added with the Kubernetes API-key Secret was silently
useless in the only form anyone would write it. The reference check sat in the
same switch as the quote-state handling, below the `inDouble`/`inSingle`
branches, so both of those branches consumed the bytes and a reference inside a
quoted scalar was copied through verbatim. `api_keys: ["${KEY}"]` — the exact
form deploy/k8s/00-config.yaml uses — came out of Load unchanged, so the key
arrived as the literal string "${FANCYDNS_API_KEY}" and the admin API was
protected by nothing. Everything looked fine because no test asserted on the
quoted form at the Load boundary until now.
Two further corrections while in there:
- A variable that is set but empty now expands to empty instead of being an
error. `${X}`, `${X-y}` and `${X:-y}` differ precisely on that point, and
getting it wrong turns an intentionally blank secret into a startup failure.
- A reference is skipped when it follows a backslash, so "\${FOO}" is literal.
Also fixes two test fixtures that had an `inline:` key, which is not a field —
the struct spells it block_entries — and were failing Validate for a reason
that had nothing to do with what they were testing. That is the same
silent-ignore-unknown-keys behaviour flagged in STATUS.md, caught here by a
test that thought it was testing environment expansion.
347 lines
12 KiB
Go
347 lines
12 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"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) {
|
|
// A bare "$" is not a reference: a blocklist URL with a query string must
|
|
// survive untouched. The "#" cases are comments, and a comment must not be
|
|
// scanned for references at all — documenting "${VAR}" in a config comment
|
|
// must not make the config unloadable.
|
|
// A bare "$" is not a reference: a blocklist URL with a query string must
|
|
// survive untouched. A real comment must not be scanned for references at
|
|
// all — documenting "${VAR}" in a config comment must not make the config
|
|
// unloadable. (A "#" inside quotes is data, not a comment, so a reference
|
|
// there *is* live; that is covered by TestExpandEnvSubstitution.)
|
|
raw := []byte("# no references here\nurl: https://x.invalid/?a=$b&c=$d\n# and ${FD_TEST_MISSING} on a comment line\n")
|
|
clearEnv(t, "FD_TEST_MISSING")
|
|
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}
|
|
blocklist:
|
|
block_entries:
|
|
- "example.com"
|
|
`)
|
|
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}\"]\nblocklist:\n block_entries:\n - \"example.com\"\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
|
|
}
|