package config import ( "net/netip" "os" "path/filepath" "reflect" "slices" "strings" "testing" "time" "sneak.berlin/go/pixa/internal/globals" "sneak.berlin/go/pixa/internal/logger" ) // TestMain unsets PORT and every PIXA_ environment variable before the // tests run, so each test sees only the variables it sets itself, not // whatever the shell running the tests exports. func TestMain(m *testing.M) { for _, entry := range os.Environ() { name, _, _ := strings.Cut(entry, "=") if name != "PORT" && !strings.HasPrefix(name, "PIXA_") { continue } err := os.Unsetenv(name) if err != nil { panic(err) } } m.Run() } // wantStartupError fails the test unless err is a startup error that // mentions every one of wants. func wantStartupError(t *testing.T, err error, wants ...string) { t.Helper() if err == nil { t.Fatalf("want a startup error mentioning %q, got none", wants) } t.Logf("got expected error: %v", err) for _, want := range wants { if !strings.Contains(err.Error(), want) { t.Errorf("error %q does not mention %q", err.Error(), want) } } } // TestEnvironmentSetsEveryKey sets every key from its environment // variable, with no config file at all: PORT for the port, and PIXA_ // plus the key in upper case, "." written as "_", for every other key. func TestEnvironmentSetsEveryKey(t *testing.T) { t.Setenv("PIXA_DEBUG", "true") t.Setenv("PIXA_MAINTENANCE_MODE", "1") t.Setenv("PORT", "9090") t.Setenv("PIXA_STATE_DIR", "/srv/pixa-env") t.Setenv("PIXA_SENTRY_DSN", "https://abc123@sentry.example.com/42") t.Setenv("PIXA_DB_URL", "file:/srv/pixa-env/other.sqlite3") t.Setenv("PIXA_METRICS_USERNAME", "metricsuser") t.Setenv("PIXA_METRICS_PASSWORD", "metricspass") t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey) t.Setenv("PIXA_ALLOWLIST_HOSTS", "s3.sneak.cloud,.example.com") t.Setenv("PIXA_ALLOW_HTTP", "true") t.Setenv("PIXA_UPSTREAM_CONNECTIONS_PER_HOST", "5") t.Setenv("PIXA_UPSTREAM_CONNECTIONS", "10") t.Setenv("PIXA_MAX_CONCURRENT_PROCESSING", "3") t.Setenv("PIXA_CACHE_MAX_BYTES", "1024") t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24") t.Setenv("PIXA_TRUSTED_PROXIES", "192.0.2.0/24") t.Setenv("PIXA_ACCESS_CONTROL_ALLOW_ORIGIN", "https://app.example.com") t.Setenv("PIXA_UPSTREAM_FETCH_TIMEOUT", "10s") t.Setenv("PIXA_UPSTREAM_MAX_RESPONSE_SIZE", "1048576") t.Setenv("PIXA_DOWNSTREAM_TIMEOUT", "2m") c, err := newFromSmartConfig(nil) if err != nil { t.Fatalf("configuration from the environment alone should load: %v", err) } want := Config{ Debug: true, MaintenanceMode: true, Port: 9090, StateDir: "/srv/pixa-env", SentryDSN: "https://abc123@sentry.example.com/42", DBURL: "file:/srv/pixa-env/other.sqlite3", MetricsUsername: "metricsuser", MetricsPassword: "metricspass", SigningKey: validTestSigningKey, AllowlistHosts: []string{testHostS3, ".example.com"}, AllowHTTP: true, UpstreamConnectionsPerHost: 5, UpstreamConnections: 10, MaxConcurrentProcessing: 3, CacheMaxBytes: 1024, cacheMaxBytesExplicit: true, BlockedNetworks: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}, TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}, AccessControlAllowOrigin: "https://app.example.com", UpstreamFetchTimeout: 10 * time.Second, UpstreamMaxResponseSize: 1048576, DownstreamTimeout: 2 * time.Minute, } if !reflect.DeepEqual(*c, want) { t.Errorf("config from the environment =\n%+v\nwant\n%+v", *c, want) } } // TestUnknownPixaVariableAbortsStartup checks that a PIXA_ variable that // is not a setting's variable, such as a misspelled one, aborts startup // naming it, as an unknown config key does, instead of being ignored. func TestUnknownPixaVariableAbortsStartup(t *testing.T) { t.Setenv("PIXA_TRUSTED_PROXY", "192.0.2.0/24") t.Setenv("PIXA_SIGNINGKEY", validTestSigningKey) err := validateKnownEnvVars() wantStartupError(t, err, "PIXA_TRUSTED_PROXY", "PIXA_SIGNINGKEY") } // TestPixaPortAbortsStartupPointingToPort checks that PIXA_PORT aborts // startup with a message saying to use PORT, which sets the port. func TestPixaPortAbortsStartupPointingToPort(t *testing.T) { t.Setenv("PIXA_PORT", "9090") err := validateKnownEnvVars() wantStartupError(t, err, "PIXA_PORT", "use PORT") } // TestSettingVariablesAndConfigPathAreAccepted checks that every // setting's variable and PIXA_CONFIG_PATH pass the check for unknown // PIXA_ variables. TestEnvironmentSetsEveryKey pins the names in the list. func TestSettingVariablesAndConfigPathAreAccepted(t *testing.T) { // A config file's env section loaded by another test can leave a // PIXA_ variable set for the whole process, so every one is unset // here first; t.Setenv restores each when the test ends. for _, entry := range os.Environ() { name, _, _ := strings.Cut(entry, "=") if !strings.HasPrefix(name, "PIXA_") { continue } t.Setenv(name, "") err := os.Unsetenv(name) if err != nil { t.Fatalf("failed to unset %s: %v", name, err) } } t.Setenv("PIXA_CONFIG_PATH", "/etc/pixa/config.yml") for _, name := range envVarNames() { t.Setenv(name, "") } err := validateKnownEnvVars() if err != nil { t.Fatalf("PIXA_CONFIG_PATH and every setting's variable "+ "must be accepted: %v", err) } } // configFromNew writes yamlContent to a temporary config file, points // PIXA_CONFIG_PATH at it, and runs New, as the server does at startup. // The state directory is a temporary one and the disk cache is off, so // New succeeds unless something in the test is wrong. func configFromNew(t *testing.T, yamlContent string) (*Config, error) { t.Helper() tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.yml") err := os.WriteFile(configPath, []byte(yamlContent), 0o600) if err != nil { t.Fatalf("failed to write test config: %v", err) } t.Setenv("PIXA_CONFIG_PATH", configPath) t.Setenv("PIXA_STATE_DIR", filepath.Join(tmpDir, "state")) t.Setenv("PIXA_CACHE_MAX_BYTES", "0") testLogger, err := logger.New(nil, logger.Params{Globals: &globals.Globals{}}) if err != nil { t.Fatalf("failed to create logger: %v", err) } return New(nil, Params{Logger: testLogger}) } // TestUnknownPixaVariableAbortsNew checks that New, which the server // calls at startup, aborts on a misspelled PIXA_ variable. func TestUnknownPixaVariableAbortsNew(t *testing.T) { t.Setenv("PIXA_TRUSTED_PROXY", "192.0.2.0/24") _, err := configFromNew(t, signingKeyLine) wantStartupError(t, err, "PIXA_TRUSTED_PROXY") } // TestUnknownPixaVariableInEnvSectionAbortsNew checks that New aborts // on a misspelled PIXA_ name in the config file's env section, which // loading the file sets as an environment variable. func TestUnknownPixaVariableInEnvSectionAbortsNew(t *testing.T) { // The variable must be absent until the file loads. t.Setenv makes // sure the one the file sets is removed when the test ends. t.Setenv("PIXA_TRUSTED_PROXY", "") err := os.Unsetenv("PIXA_TRUSTED_PROXY") if err != nil { t.Fatalf("failed to unset PIXA_TRUSTED_PROXY: %v", err) } _, err = configFromNew(t, signingKeyLine+ "env:\n PIXA_TRUSTED_PROXY: 192.0.2.0/24\n") wantStartupError(t, err, "PIXA_TRUSTED_PROXY") } // TestPortFromEnvironmentOverridesConfigFile checks that PORT wins over // the port in the config file. func TestPortFromEnvironmentOverridesConfigFile(t *testing.T) { t.Setenv("PORT", "9090") c, err := configFromYAML(t, signingKeyLine+"port: 8080\n") if err != nil { t.Fatalf("PORT=9090 with port 8080 in the file should load: %v", err) } if c.Port != 9090 { t.Errorf("Port = %d, want 9090 from PORT, not 8080 from the file", c.Port) } } // TestInvalidPortFromEnvironmentAbortsStartup checks that a PORT that is // not a number, or is outside the port range, aborts startup naming PORT // and the value, even though the file's port is valid. func TestInvalidPortFromEnvironmentAbortsStartup(t *testing.T) { t.Setenv("PORT", "banana") _, err := configFromYAML(t, signingKeyLine+"port: 8080\n") wantStartupError(t, err, "PORT", "banana") t.Setenv("PORT", "70000") _, err = configFromYAML(t, signingKeyLine+"port: 8080\n") wantStartupError(t, err, "PORT", "70000") } // TestListFromEnvironmentReplacesConfigFileList checks that a list // variable replaces the file's list, split on commas with the spaces // around each entry trimmed. func TestListFromEnvironmentReplacesConfigFileList(t *testing.T) { t.Setenv("PIXA_ALLOWLIST_HOSTS", " cdn.example.com , .example.org ") c, err := configFromYAML(t, signingKeyLine+ "allowlist_hosts:\n - s3.sneak.cloud\n - sneak.berlin\n") if err != nil { t.Fatalf("PIXA_ALLOWLIST_HOSTS should load: %v", err) } want := []string{"cdn.example.com", ".example.org"} if !slices.Equal(c.AllowlistHosts, want) { t.Errorf("AllowlistHosts = %v, want %v from PIXA_ALLOWLIST_HOSTS", c.AllowlistHosts, want) } } // TestInvalidBlockedNetworksFromEnvironmentAbortsStartup checks that an // invalid CIDR, or an empty entry, in PIXA_BLOCKED_NETWORKS aborts // startup naming the variable, as the same list in the file does. func TestInvalidBlockedNetworksFromEnvironmentAbortsStartup(t *testing.T) { t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24,not-a-cidr") _, err := configFromYAML(t, signingKeyLine) wantStartupError(t, err, "PIXA_BLOCKED_NETWORKS", "not-a-cidr") t.Setenv("PIXA_BLOCKED_NETWORKS", "203.0.113.0/24,,198.51.100.0/24") _, err = configFromYAML(t, signingKeyLine) wantStartupError(t, err, "PIXA_BLOCKED_NETWORKS") } // TestInvalidDebugFromEnvironmentAbortsStartup checks that a PIXA_DEBUG // that strconv.ParseBool rejects aborts startup instead of defaulting. func TestInvalidDebugFromEnvironmentAbortsStartup(t *testing.T) { t.Setenv("PIXA_DEBUG", "maybe") _, err := configFromYAML(t, signingKeyLine) wantStartupError(t, err, "PIXA_DEBUG", "maybe") } // TestInvalidOriginTimeoutOrSizeFromEnvironmentAbortsStartup checks that // an invalid CORS origin, timeout or response size limit in its variable // aborts startup naming the variable and the value. func TestInvalidOriginTimeoutOrSizeFromEnvironmentAbortsStartup(t *testing.T) { cases := []struct { variable string value string }{ {"PIXA_ACCESS_CONTROL_ALLOW_ORIGIN", "example.com"}, {"PIXA_UPSTREAM_FETCH_TIMEOUT", "soon"}, {"PIXA_UPSTREAM_MAX_RESPONSE_SIZE", "50MB"}, {"PIXA_DOWNSTREAM_TIMEOUT", "0s"}, } for _, tc := range cases { t.Run(tc.variable, func(t *testing.T) { t.Setenv(tc.variable, tc.value) _, err := configFromYAML(t, signingKeyLine) wantStartupError(t, err, tc.variable, tc.value) }) } } // TestConfigFileAloneBehavesAsBefore checks that with no variables set // (TestMain unsets them) the config file's values are used and omitted // keys take their defaults. func TestConfigFileAloneBehavesAsBefore(t *testing.T) { t.Parallel() c, err := configFromYAML(t, signingKeyLine+"port: 9191\n") if err != nil { t.Fatalf("config file should load: %v", err) } if c.Port != 9191 { t.Errorf("Port = %d, want 9191 from the file", c.Port) } if c.StateDir != DefaultStateDir { t.Errorf("StateDir = %q, want default %q", c.StateDir, DefaultStateDir) } if !slices.Equal(c.TrustedProxies, defaultTrustedProxies()) { t.Errorf("TrustedProxies = %v, want default %v", c.TrustedProxies, defaultTrustedProxies()) } } // TestEmptyTrustedProxiesFromEnvironmentTrustsNoOne checks that an empty // PIXA_TRUSTED_PROXIES is an empty list, like [] in the file: it trusts // no proxy instead of taking the default ranges. func TestEmptyTrustedProxiesFromEnvironmentTrustsNoOne(t *testing.T) { t.Setenv("PIXA_TRUSTED_PROXIES", "") c, err := configFromYAML(t, signingKeyLine) if err != nil { t.Fatalf("empty PIXA_TRUSTED_PROXIES should load: %v", err) } if len(c.TrustedProxies) != 0 { t.Errorf("TrustedProxies = %v, want none", c.TrustedProxies) } } // TestEmptyVariableDoesNotFallBackToConfigFile checks that a variable // that is present but empty is a set value: an empty PIXA_STATE_DIR // aborts startup like state_dir: "" in the file, instead of falling // through to the file's state_dir. func TestEmptyVariableDoesNotFallBackToConfigFile(t *testing.T) { t.Setenv("PIXA_STATE_DIR", "") _, err := configFromYAML(t, signingKeyLine+"state_dir: /srv/pixa-file\n") wantStartupError(t, err, "PIXA_STATE_DIR") } // TestMissingSigningKeyNamesItsVariable checks that with no config file // and no PIXA_SIGNING_KEY, startup aborts naming the variable, which is // how a container started without it reports the problem. func TestMissingSigningKeyNamesItsVariable(t *testing.T) { t.Parallel() _, err := newFromSmartConfig(nil) wantStartupError(t, err, "PIXA_SIGNING_KEY") } // TestSecretsFromEnvironmentAreNotPrinted checks that errors about the // signing key and the metrics password name their variables but never // print their values. func TestSecretsFromEnvironmentAreNotPrinted(t *testing.T) { t.Setenv("PIXA_SIGNING_KEY", "short-signing-secret") _, err := newFromSmartConfig(nil) wantStartupError(t, err, "PIXA_SIGNING_KEY") if strings.Contains(err.Error(), "short-signing-secret") { t.Errorf("error %q prints the signing key", err.Error()) } t.Setenv("PIXA_SIGNING_KEY", validTestSigningKey) t.Setenv("PIXA_METRICS_PASSWORD", "metrics-password-secret") _, err = newFromSmartConfig(nil) wantStartupError(t, err, "PIXA_METRICS_PASSWORD") if strings.Contains(err.Error(), "metrics-password-secret") { t.Errorf("error %q prints the metrics password", err.Error()) } }