package config import ( "log/slog" "os" "path/filepath" "strings" "testing" "git.eeqj.de/sneak/smartconfig" ) // validTestSigningKey is a 32-character signing key that satisfies the // minimum length requirement in validate(). const validTestSigningKey = "0123456789abcdef0123456789abcdef" // signingKeyLine is a valid signing_key config line used as the base of // test config files. const signingKeyLine = "signing_key: " + validTestSigningKey + "\n" // testHostS3 is an allowlist host entry used across the config tests. const testHostS3 = "s3.sneak.cloud" // nullValueText is the substring that error messages about explicitly // null config values must contain. const nullValueText = "null" // abortCase describes a config file that must abort startup with an // error mentioning every string in wantErrSubstrings. type abortCase struct { name string yaml string // wantErrSubstrings must all appear in the error message. wantErrSubstrings []string } // configFromYAML writes yamlContent to a temporary config file, loads it // via smartconfig, and constructs a Config from it using the same code // path the server uses at startup. func configFromYAML(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) } sc, err := smartconfig.NewFromConfigPath(configPath) if err != nil { t.Fatalf("failed to load test config: %v", err) } return newFromSmartConfig(sc) } func TestOmittedValuesUseDefaults(t *testing.T) { t.Parallel() c, err := configFromYAML(t, signingKeyLine) if err != nil { t.Fatalf("minimal config should be valid, got error: %v", err) } if c.Port != DefaultPort { t.Errorf("Port = %d, want default %d", c.Port, DefaultPort) } if c.StateDir != DefaultStateDir { t.Errorf("StateDir = %q, want default %q", c.StateDir, DefaultStateDir) } if c.UpstreamConnectionsPerHost != DefaultUpstreamConnectionsPerHost { t.Errorf("UpstreamConnectionsPerHost = %d, want default %d", c.UpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost) } if c.Debug { t.Error("Debug = true, want default false") } if c.MaintenanceMode { t.Error("MaintenanceMode = true, want default false") } if c.AllowHTTP { t.Error("AllowHTTP = true, want default false") } if len(c.AllowlistHosts) != 0 { t.Errorf("AllowlistHosts = %v, want empty", c.AllowlistHosts) } wantDBURL := "file:" + DefaultStateDir + "/state.sqlite3?_journal_mode=WAL" if c.DBURL != wantDBURL { t.Errorf("DBURL = %q, want derived default %q", c.DBURL, wantDBURL) } } func TestExplicitValidValuesAreUsed(t *testing.T) { t.Parallel() yamlContent := ` port: 9090 debug: true maintenance_mode: true state_dir: /tmp/pixa-test-state db_url: "file:/tmp/pixa-test-state/other.sqlite3" signing_key: ` + validTestSigningKey + ` allowlist_hosts: - s3.sneak.cloud - .example.com allow_http: true upstream_connections_per_host: 5 sentry_dsn: "https://abc123@sentry.example.com/42" metrics: username: metricsuser password: metricspass ` c, err := configFromYAML(t, yamlContent) if err != nil { t.Fatalf("valid config should load, got error: %v", err) } if c.Port != 9090 { t.Errorf("Port = %d, want 9090", c.Port) } if !c.Debug || !c.MaintenanceMode || !c.AllowHTTP { t.Errorf("bool fields = debug %v maintenance %v allow_http %v, want all true", c.Debug, c.MaintenanceMode, c.AllowHTTP) } if c.StateDir != "/tmp/pixa-test-state" { t.Errorf("StateDir = %q, want /tmp/pixa-test-state", c.StateDir) } if c.DBURL != "file:/tmp/pixa-test-state/other.sqlite3" { t.Errorf("DBURL = %q, want explicit value", c.DBURL) } if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != testHostS3 || c.AllowlistHosts[1] != ".example.com" { t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud .example.com]", c.AllowlistHosts) } if c.UpstreamConnectionsPerHost != 5 { t.Errorf("UpstreamConnectionsPerHost = %d, want 5", c.UpstreamConnectionsPerHost) } if c.SentryDSN != "https://abc123@sentry.example.com/42" { t.Errorf("SentryDSN = %q, want explicit value", c.SentryDSN) } if c.MetricsUsername != "metricsuser" || c.MetricsPassword != "metricspass" { t.Errorf("metrics = %q/%q, want metricsuser/metricspass", c.MetricsUsername, c.MetricsPassword) } } func TestCommaSeparatedAllowlistStillSupported(t *testing.T) { t.Parallel() yamlContent := signingKeyLine + `allowlist_hosts: "s3.sneak.cloud, sneak.berlin" ` c, err := configFromYAML(t, yamlContent) if err != nil { t.Fatalf("comma-separated allowlist should load, got error: %v", err) } if len(c.AllowlistHosts) != 2 || c.AllowlistHosts[0] != testHostS3 || c.AllowlistHosts[1] != "sneak.berlin" { t.Errorf("AllowlistHosts = %v, want [s3.sneak.cloud sneak.berlin]", c.AllowlistHosts) } } // runAbortCases asserts that each case's config aborts startup with an // error message mentioning every expected substring. func runAbortCases(t *testing.T, cases []abortCase) { t.Helper() for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() c, err := configFromYAML(t, tc.yaml) if err == nil { t.Fatalf("config with %s must abort startup, got config: %+v", tc.name, c) } t.Logf("got expected error: %v", err) for _, want := range tc.wantErrSubstrings { if !strings.Contains(err.Error(), want) { t.Errorf("error %q does not mention %q", err.Error(), want) } } }) } } // invalidScalarValueCases are configs where a scalar key is explicitly // set to an unparseable or out-of-range value; each must abort startup // naming the offending key, never silently fall back to the default. func invalidScalarValueCases() []abortCase { return []abortCase{ { name: "port not a number", yaml: signingKeyLine + "port: banana\n", wantErrSubstrings: []string{keyPort, "banana"}, }, { name: "port zero", yaml: signingKeyLine + "port: 0\n", wantErrSubstrings: []string{keyPort, "0"}, }, { name: "port above 65535", yaml: signingKeyLine + "port: 99999\n", wantErrSubstrings: []string{keyPort, "99999"}, }, { name: "port fractional", yaml: signingKeyLine + "port: 8080.5\n", wantErrSubstrings: []string{keyPort, "8080.5"}, }, { name: "debug not a bool", yaml: signingKeyLine + "debug: notabool\n", wantErrSubstrings: []string{keyDebug, "notabool"}, }, { name: "maintenance_mode not a bool", yaml: signingKeyLine + "maintenance_mode: sometimes\n", wantErrSubstrings: []string{keyMaintenanceMode, "sometimes"}, }, { name: "allow_http numeric", yaml: signingKeyLine + "allow_http: 2\n", wantErrSubstrings: []string{keyAllowHTTP, "2"}, }, { name: "upstream_connections_per_host zero", yaml: signingKeyLine + "upstream_connections_per_host: 0\n", wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "0"}, }, { name: "upstream_connections_per_host negative", yaml: signingKeyLine + "upstream_connections_per_host: -3\n", wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "-3"}, }, { name: "upstream_connections_per_host not a number", yaml: signingKeyLine + "upstream_connections_per_host: many\n", wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, "many"}, }, } } // invalidHostAndCredentialCases are configs where allowlist_hosts, // signing_key, state_dir, sentry_dsn, or metrics is explicitly set to // an invalid value; each must abort startup naming the offending key. func invalidHostAndCredentialCases() []abortCase { return []abortCase{ { name: "allowlist host with scheme", yaml: signingKeyLine + "allowlist_hosts:\n - https://example.com\n", wantErrSubstrings: []string{ keyAllowlistHosts, "https://example.com", }, }, { name: "allowlist host with path", yaml: signingKeyLine + "allowlist_hosts:\n - example.com/images\n", wantErrSubstrings: []string{ keyAllowlistHosts, "example.com/images", }, }, { name: "allowlist host with whitespace", yaml: signingKeyLine + "allowlist_hosts:\n - \"exa mple.com\"\n", wantErrSubstrings: []string{keyAllowlistHosts, "exa mple.com"}, }, { name: "allowlist entry not a string", yaml: signingKeyLine + "allowlist_hosts:\n - 123\n", wantErrSubstrings: []string{keyAllowlistHosts, "123"}, }, { name: "allowlist not a list", yaml: signingKeyLine + "allowlist_hosts:\n key: value\n", wantErrSubstrings: []string{keyAllowlistHosts}, }, { name: "signing_key too short", yaml: "signing_key: short\n", wantErrSubstrings: []string{keySigningKey}, }, { name: "signing_key missing", yaml: "port: 8080\n", wantErrSubstrings: []string{keySigningKey}, }, { name: "state_dir explicitly empty", yaml: signingKeyLine + "state_dir: \"\"\n", wantErrSubstrings: []string{keyStateDir}, }, { name: "sentry_dsn not a URL", yaml: signingKeyLine + "sentry_dsn: \"not a url\"\n", wantErrSubstrings: []string{keySentryDSN, "not a url"}, }, { name: "metrics username without password", yaml: signingKeyLine + "metrics:\n username: bob\n", wantErrSubstrings: []string{keyMetrics}, }, { name: "metrics password without username", yaml: signingKeyLine + "metrics:\n password: hunter2\n", wantErrSubstrings: []string{keyMetrics}, }, } } // TestSetButInvalidValueAbortsStartup verifies the no-silent-fallback // rule: a key that is explicitly set to an unparseable or out-of-range // value must produce a startup error naming the offending key, never // silently fall back to the default. func TestSetButInvalidValueAbortsStartup(t *testing.T) { t.Parallel() runAbortCases(t, append( invalidScalarValueCases(), invalidHostAndCredentialCases()...)) } // explicitNullValueCases are configs where a key is explicitly set to // null (including the bare "key:" form and the "~" alias); each must // abort startup naming the key. func explicitNullValueCases() []abortCase { return []abortCase{ { name: "port explicit null", yaml: signingKeyLine + "port: null\n", wantErrSubstrings: []string{keyPort, nullValueText}, }, { name: "port bare key no value", yaml: signingKeyLine + "port:\n", wantErrSubstrings: []string{keyPort, nullValueText}, }, { name: "debug tilde null", yaml: signingKeyLine + "debug: ~\n", wantErrSubstrings: []string{keyDebug, nullValueText}, }, { name: "maintenance_mode null", yaml: signingKeyLine + "maintenance_mode: null\n", wantErrSubstrings: []string{keyMaintenanceMode, nullValueText}, }, { name: "allow_http null", yaml: signingKeyLine + "allow_http: null\n", wantErrSubstrings: []string{keyAllowHTTP, nullValueText}, }, { name: "state_dir null", yaml: signingKeyLine + "state_dir: null\n", wantErrSubstrings: []string{keyStateDir, nullValueText}, }, { name: "db_url null", yaml: signingKeyLine + "db_url: null\n", wantErrSubstrings: []string{keyDBURL, nullValueText}, }, { name: "sentry_dsn null", yaml: signingKeyLine + "sentry_dsn: null\n", wantErrSubstrings: []string{keySentryDSN, nullValueText}, }, { name: "upstream_connections_per_host null", yaml: signingKeyLine + "upstream_connections_per_host: null\n", wantErrSubstrings: []string{keyUpstreamConnectionsPerHost, nullValueText}, }, { name: "allowlist_hosts null", yaml: signingKeyLine + "allowlist_hosts: null\n", wantErrSubstrings: []string{keyAllowlistHosts, nullValueText}, }, { name: "signing_key null", yaml: "signing_key: null\n", wantErrSubstrings: []string{keySigningKey, nullValueText}, }, { name: "metrics null", yaml: signingKeyLine + "metrics: null\n", wantErrSubstrings: []string{keyMetrics, nullValueText}, }, { name: "metrics subkeys null", yaml: signingKeyLine + "metrics:\n username: null\n password: null\n", wantErrSubstrings: []string{ keyMetricsUsername, keyMetricsPassword, nullValueText, }, }, } } // TestExplicitNullValueAbortsStartup verifies that a key explicitly // set to null (including the bare "key:" form and the "~" alias) aborts // startup naming the key. An explicit null is a SET value: it must // never silently fall back to the default the way an omitted key does. func TestExplicitNullValueAbortsStartup(t *testing.T) { t.Parallel() runAbortCases(t, explicitNullValueCases()) } // TestExplicitlyEmptyDBURLAbortsStartup verifies that db_url set to an // empty string aborts startup: the derived file:...state.sqlite3 URL is // a default, and defaults apply only to omitted keys. This matches // state_dir, where an explicitly empty value already aborts. func TestExplicitlyEmptyDBURLAbortsStartup(t *testing.T) { t.Parallel() yamlContent := signingKeyLine + "db_url: \"\"\n" c, err := configFromYAML(t, yamlContent) if err == nil { t.Fatalf("explicitly empty db_url must abort startup, got config: %+v", c) } t.Logf("got expected error: %v", err) if !strings.Contains(err.Error(), keyDBURL) { t.Errorf("error %q does not name the offending key db_url", err.Error()) } } // TestAllowlistHostsRejectsDotOnlyEntries verifies that entries with no // hostname labels are rejected. The allowlist matcher treats a leading // dot as a suffix pattern, so a bare "." entry would match any upstream // host written in FQDN trailing-dot form (e.g. evil.com.) and // effectively disable URL signing with a single character. func TestAllowlistHostsRejectsDotOnlyEntries(t *testing.T) { t.Parallel() for _, entry := range []string{".", ".."} { t.Run(entry, func(t *testing.T) { t.Parallel() yamlContent := signingKeyLine + "allowlist_hosts:\n - \"" + entry + "\"\n" c, err := configFromYAML(t, yamlContent) if err == nil { t.Fatalf("allowlist entry %q must abort startup, got config: %+v", entry, c) } t.Logf("got expected error: %v", err) if !strings.Contains(err.Error(), keyAllowlistHosts) { t.Errorf("error %q does not name the offending key allowlist_hosts", err.Error()) } }) } } func TestUnknownTopLevelKeyAbortsStartup(t *testing.T) { t.Parallel() yamlContent := signingKeyLine + `whitelist_hosts: - example.com ` c, err := configFromYAML(t, yamlContent) if err == nil { t.Fatalf("config with unknown key must abort startup, got config: %+v", c) } t.Logf("got expected error: %v", err) if !strings.Contains(err.Error(), "whitelist_hosts") { t.Errorf("error %q does not name the unknown key whitelist_hosts", err.Error()) } } func TestUnknownMetricsSubkeyAbortsStartup(t *testing.T) { t.Parallel() yamlContent := signingKeyLine + `metrics: username: bob password: hunter2 port: 9100 ` c, err := configFromYAML(t, yamlContent) if err == nil { t.Fatalf("config with unknown metrics subkey must abort startup, got config: %+v", c) } t.Logf("got expected error: %v", err) if !strings.Contains(err.Error(), "metrics.port") { t.Errorf("error %q does not name the unknown key metrics.port", err.Error()) } } func TestEnvSectionIsPermitted(t *testing.T) { t.Parallel() yamlContent := signingKeyLine + `env: PIXA_TEST_ENV_INJECTION: injected ` _, err := configFromYAML(t, yamlContent) if err != nil { t.Fatalf( "env section must be permitted (smartconfig consumes it), got error: %v", err) } } func TestMalformedConfigFileAbortsStartup(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.yml") err := os.WriteFile(configPath, []byte("port: [unclosed\n"), 0o600) if err != nil { t.Fatalf("failed to write malformed config: %v", err) } // loadConfigFile falls through to the relative config.yml candidate; // the appname is chosen so no /etc or $HOME candidate can exist. t.Setenv("PIXA_CONFIG_PATH", "") t.Chdir(tmpDir) log := slog.New(slog.DiscardHandler) sc, err := loadConfigFile(log, "pixa-test-nonexistent-app") if err == nil { t.Fatalf("malformed config file must abort startup, got config: %v", sc) } t.Logf("got expected error: %v", err) } func TestEnsureStateDirCreatesDirectory(t *testing.T) { t.Parallel() stateDir := filepath.Join(t.TempDir(), "nested", "state") c := &Config{StateDir: stateDir} err := c.ensureStateDirWritable() if err != nil { t.Fatalf("creatable state_dir must validate, got error: %v", err) } info, err := os.Stat(stateDir) if err != nil || !info.IsDir() { t.Fatalf("state_dir was not created: info=%v err=%v", info, err) } } func TestEnsureStateDirFailsOnUncreatablePath(t *testing.T) { t.Parallel() // A path below /dev/null can never be created, even when running // as root (as in the Docker build). c := &Config{StateDir: "/dev/null/pixa-state"} err := c.ensureStateDirWritable() if err == nil { t.Fatal("uncreatable state_dir must abort startup, got nil error") } t.Logf("got expected error: %v", err) if !strings.Contains(err.Error(), keyStateDir) { t.Errorf("error %q does not name the offending key state_dir", err.Error()) } }