package config import ( "io" "os" "path/filepath" "strings" "testing" "time" ) func TestDefaultIsValid(t *testing.T) { cfg := Default() if err := cfg.Validate(); err != nil { t.Fatalf("the built-in default must validate: %v", err) } if len(cfg.TrustedProxies()) != 2 { t.Errorf("trusted proxies = %v, want loopback v4 + v6", cfg.TrustedProxies()) } } func writeConfig(t *testing.T, body string) string { t.Helper() path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } return path } // Precedence is flag > env > file > default, and — the part that is easy to get // wrong — a flag left unset must not shadow the file or the environment with its // default value. func TestLoadPrecedence(t *testing.T) { path := writeConfig(t, ` data_dir = "/srv/from-file" webroot = "/var/www/from-file" listen = ":9001" log_level = "warn" gc_interval = "9m" `) t.Setenv("PAGES_WEBROOT", "/var/www/from-env") t.Setenv("PAGES_LOG_LEVEL", "debug") opts, err := Load([]string{ "-config", path, "-log-level", "error", "-gc-interval", "1m", }, io.Discard) if err != nil { t.Fatalf("Load: %v", err) } cfg := opts.Config if cfg.LogLevel != "error" { t.Errorf("log_level = %q, want the flag to win", cfg.LogLevel) } if cfg.Webroot != "/var/www/from-env" { t.Errorf("webroot = %q, want the env to beat the file", cfg.Webroot) } if cfg.DataDir != "/srv/from-file" { t.Errorf("data_dir = %q, want the file to beat the default", cfg.DataDir) } if cfg.Listen != ":9001" { t.Errorf("listen = %q, want the file value (no flag, no env)", cfg.Listen) } if cfg.GCInterval.D() != time.Minute { t.Errorf("gc_interval = %s, want the flag to win", cfg.GCInterval) } if cfg.APIListen != Default().APIListen { t.Errorf("api_listen = %q, want the untouched default", cfg.APIListen) } } // An unset flag defaults to the same value as Default(), so a naive // implementation silently overwrites whatever the file said with that default. func TestUnsetFlagDoesNotShadowFile(t *testing.T) { path := writeConfig(t, "log_format = \"text\"\napi_listen = \"127.0.0.1:9999\"\n") opts, err := Load([]string{"-config", path}, io.Discard) if err != nil { t.Fatalf("Load: %v", err) } if opts.Config.LogFormat != "text" { t.Errorf("log_format = %q, want text", opts.Config.LogFormat) } if opts.Config.APIListen != "127.0.0.1:9999" { t.Errorf("api_listen = %q, want the file value", opts.Config.APIListen) } } func TestLoadRejectsUnknownKey(t *testing.T) { path := writeConfig(t, "data_dir = \"/srv/x\"\nlog_levle = \"debug\"\n") _, err := Load([]string{"-config", path}, io.Discard) if err == nil { t.Fatal("a typo in the config file must fail loudly") } if !strings.Contains(err.Error(), "log_levle") { t.Errorf("error should name the offending key, got: %v", err) } } func TestLoadRejectsUnknownArgs(t *testing.T) { if _, err := Load([]string{"serve"}, io.Discard); err == nil { t.Fatal("positional arguments must be rejected") } } func TestLoadEnvDurationError(t *testing.T) { t.Setenv("PAGES_GC_INTERVAL", "fifteen minutes") _, err := Load(nil, io.Discard) if err == nil || !strings.Contains(err.Error(), "PAGES_GC_INTERVAL") { t.Fatalf("want an error naming the variable, got %v", err) } } func TestLoadTrustedProxiesFromEnv(t *testing.T) { t.Setenv("PAGES_TRUSTED_PROXY_CIDRS", "10.0.0.0/8, 192.168.0.0/16 ,") opts, err := Load(nil, io.Discard) if err != nil { t.Fatalf("Load: %v", err) } got := opts.Config.TrustedProxies() if len(got) != 2 || got[0].String() != "10.0.0.0/8" || got[1].String() != "192.168.0.0/16" { t.Errorf("trusted proxies = %v", got) } } func TestValidate(t *testing.T) { cases := []struct { name string mutate func(*Config) wantErr string }{ {"webroot inside data_dir", func(c *Config) { c.DataDir = "/var/lib/pages" c.Webroot = "/var/lib/pages/www" }, "nested"}, {"data_dir inside webroot", func(c *Config) { c.DataDir = "/srv/www/state" c.Webroot = "/srv/www" }, "nested"}, {"identical dirs", func(c *Config) { c.DataDir = "/srv/www" c.Webroot = "/srv/www" }, "must differ"}, {"listeners collide", func(c *Config) { c.Listen = "127.0.0.1:8080" c.APIListen = "127.0.0.1:8080" }, "must differ"}, {"listen without port", func(c *Config) { c.Listen = "8080" }, "listen"}, {"bad cidr", func(c *Config) { c.TrustedProxyCIDRs = []string{"10.0.0.1"} }, "trusted_proxy_cidrs"}, {"bad assemble mode", func(c *Config) { c.AssembleMode = "symlink" }, "assemble_mode"}, {"bad log level", func(c *Config) { c.LogLevel = "verbose" }, "log_level"}, {"zero interval", func(c *Config) { c.GCInterval = 0 }, "gc_interval"}, {"negative limit", func(c *Config) { c.Limits.MaxFileBytes = -1 }, "max_file_bytes"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { cfg := Default() tc.mutate(&cfg) err := cfg.Validate() if err == nil { t.Fatalf("want an error mentioning %q", tc.wantErr) } if !strings.Contains(err.Error(), tc.wantErr) { t.Errorf("error = %v, want it to mention %q", err, tc.wantErr) } }) } } // assemble_mode = "none" means no webroot is maintained, so the webroot checks // must not fire. func TestValidateAllowsEmptyWebrootWhenAssemblyDisabled(t *testing.T) { cfg := Default() cfg.AssembleMode = AssembleNone cfg.Webroot = "" if err := cfg.Validate(); err != nil { t.Fatalf("Validate: %v", err) } } func TestValidateMakesPathsAbsolute(t *testing.T) { cfg := Default() cfg.DataDir = "state/./" cfg.Webroot = "www" if err := cfg.Validate(); err != nil { t.Fatalf("Validate: %v", err) } if !filepath.IsAbs(cfg.DataDir) || strings.HasSuffix(cfg.DataDir, "/") { t.Errorf("data_dir = %q, want a cleaned absolute path", cfg.DataDir) } if !filepath.IsAbs(cfg.Webroot) { t.Errorf("webroot = %q, want an absolute path", cfg.Webroot) } } func TestEnsureDirs(t *testing.T) { base := t.TempDir() cfg := Default() cfg.DataDir = filepath.Join(base, "state") cfg.Webroot = filepath.Join(base, "www") if err := cfg.Validate(); err != nil { t.Fatal(err) } if err := cfg.EnsureDirs(); err != nil { t.Fatalf("EnsureDirs: %v", err) } for _, d := range []string{cfg.DataDir, cfg.CASDir(), filepath.Join(cfg.CASDir(), "tmp"), cfg.DeploymentsDir(), cfg.Webroot} { fi, err := os.Stat(d) if err != nil { t.Errorf("missing %s: %v", d, err) continue } if !fi.IsDir() { t.Errorf("%s is not a directory", d) } } // Idempotent: a restart must not fail on directories that already exist. if err := cfg.EnsureDirs(); err != nil { t.Fatalf("second EnsureDirs: %v", err) } } func TestProjectNamePattern(t *testing.T) { valid := []string{"a", "demo", "my-site", "my.site", "my_site", "a1", strings.Repeat("x", 63)} invalid := []string{ "", "-lead", ".lead", "_lead", "UPPER", "has space", "has/slash", "..", "a/../b", "a\\b", "tilde~", strings.Repeat("x", 64), "naïve", "a\x00b", } for _, s := range valid { if !ProjectNamePattern.MatchString(s) { t.Errorf("%q should be a valid project name", s) } } for _, s := range invalid { if ProjectNamePattern.MatchString(s) { t.Errorf("%q must be rejected as a project name", s) } } } func TestDurationRoundTrip(t *testing.T) { var d Duration if err := d.Set("15m30s"); err != nil { t.Fatal(err) } if d.D() != 15*time.Minute+30*time.Second { t.Errorf("d = %s", d) } b, err := d.MarshalText() if err != nil { t.Fatal(err) } var back Duration if err := back.UnmarshalText(b); err != nil { t.Fatal(err) } if back != d { t.Errorf("round trip: %s != %s", back, d) } if err := d.Set("soon"); err == nil { t.Error("Set must reject a non-duration") } }