404 lines
14 KiB
Go
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")
|
|
}
|
|
}
|