Files
mats d48c1863bf
build / build-push (push) Successful in 24s
more config
2026-09-28 21:34:59 +02:00

404 lines
14 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())
}
}
}
// TestDurationJSONForm pins the wire form of a Duration. This test used to
// pin the encoding/json default — a bare nanosecond count, and a quoted
// duration as an error — because a config.Duration had no custom JSON
// marshaller at all.
//
// DECIDED: that default was wrong on both sides. The admin console declares
// these fields as strings (`bootstraps[].refresh`, `healthcheck_interval`), the
// same spelling config.yaml uses, and a nanosecond count on the wire is
// something no client means. Duration now marshals to "5s" and unmarshals from
// a duration string; a bare number is accepted as SECONDS rather than
// nanoseconds, so a hand-written client asking for `{"healthcheck_interval":30}`
// gets 30s and not 30ns.
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.Second {
t.Errorf("bare JSON number = %v, want 5s (a number is seconds, not nanoseconds)", got.D.D())
}
if err := json.Unmarshal([]byte(`{"d":"5s"}`), &got); err != nil {
t.Fatalf("quoted duration: %v", err)
}
if got.D.D() != 5*time.Second {
t.Errorf("quoted JSON duration = %v, want 5s", got.D.D())
}
if err := json.Unmarshal([]byte(`{"d":"nonsense"}`), &got); err == nil {
t.Error("an unparseable duration string decoded without an error")
}
if err := json.Unmarshal([]byte(`{"d":true}`), &got); err == nil {
t.Error("a boolean decoded as a duration without an error")
}
out, err := json.Marshal(Duration(5 * time.Second))
if err != nil {
t.Fatalf("marshal: %v", err)
}
if string(out) != `"5s"` {
t.Errorf("json.Marshal = %s, want \"5s\"", out)
}
// The yaml form is unchanged, so a config file and the API agree.
y, err := Duration(90 * time.Second).MarshalYAML()
if err != nil {
t.Fatalf("MarshalYAML: %v", err)
}
if y != "1m30s" {
t.Errorf("MarshalYAML = %v, want 1m30s", y)
}
}
// 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
}
func TestForwardZoneConfig(t *testing.T) {
c := Default()
c.Block.Enabled = false
c.Upstream.ForwardZones = []ForwardZone{{Zone: "LOCAL.", Servers: []UpstreamServer{{Addr: "10.10.0.1"}}}}
c.applyDefaults()
if err := c.Validate(); err != nil {
t.Fatal(err)
}
z := c.Upstream.ForwardZones[0]
if z.Zone != "local" || z.Servers[0].Addr != "10.10.0.1:53" || z.Servers[0].Proto != "udp" {
t.Fatalf("defaults: %+v", z)
}
for _, bad := range []ForwardZone{
{Zone: "*.local", Servers: z.Servers},
{Zone: "", Servers: z.Servers},
{Zone: "local"},
{Zone: "local", Servers: []UpstreamServer{{Addr: "", Proto: "udp"}}},
{Zone: "local", Servers: []UpstreamServer{{Addr: "10.10.0.1:53", Proto: "http"}}},
} {
c.Upstream.ForwardZones = []ForwardZone{bad}
if err := c.Validate(); err == nil {
t.Fatalf("accepted %+v", bad)
}
}
c.Upstream.ForwardZones = []ForwardZone{z, z}
if err := c.Validate(); err == nil {
t.Fatal("accepted duplicate zone")
}
}