package config import ( "strings" "testing" "time" ) func withEnv(t *testing.T, key, value string) { t.Helper() t.Setenv(key, value) } func clearConfigEnv(t *testing.T) { t.Helper() keys := []string{ "SOTERIA_NAMESPACE", "SOTERIA_SECRET_NAMESPACE", "SOTERIA_BACKUP_DRIVER", "SOTERIA_RESTIC_IMAGE", "SOTERIA_RESTIC_REPOSITORY", "SOTERIA_RESTIC_SECRET_NAME", "SOTERIA_RESTIC_BACKUP_ARGS", "SOTERIA_RESTIC_FORGET_ARGS", "SOTERIA_S3_ENDPOINT", "SOTERIA_S3_REGION", "SOTERIA_JOB_SERVICE_ACCOUNT", "SOTERIA_LISTEN_ADDR", "SOTERIA_JOB_NODE_SELECTOR", "SOTERIA_LONGHORN_URL", "SOTERIA_LONGHORN_BACKUP_MODE", "SOTERIA_AUTH_REQUIRED", "SOTERIA_ALLOWED_GROUPS", "SOTERIA_AUTH_BEARER_TOKENS", "SOTERIA_METRICS_REFRESH_SECONDS", "SOTERIA_POLICY_EVAL_SECONDS", "SOTERIA_POLICY_SECRET_NAME", "SOTERIA_USAGE_SECRET_NAME", "SOTERIA_BACKUP_MAX_AGE_HOURS", "SOTERIA_B2_ENABLED", "SOTERIA_B2_ENDPOINT", "SOTERIA_B2_REGION", "SOTERIA_B2_BUCKETS", "SOTERIA_B2_ACCESS_KEY_ID", "SOTERIA_B2_SECRET_ACCESS_KEY", "SOTERIA_B2_SECRET_NAMESPACE", "SOTERIA_B2_SECRET_NAME", "SOTERIA_B2_ACCESS_KEY_FIELD", "SOTERIA_B2_SECRET_KEY_FIELD", "SOTERIA_B2_ENDPOINT_FIELD", "SOTERIA_B2_SCAN_INTERVAL_SECONDS", "SOTERIA_B2_SCAN_TIMEOUT_SECONDS", "SOTERIA_JOB_TTL_SECONDS", } for _, key := range keys { withEnv(t, key, "") } } func TestLoadDefaultsForLonghorn(t *testing.T) { clearConfigEnv(t) withEnv(t, "SOTERIA_NAMESPACE", "soteria") cfg, err := Load() if err != nil { t.Fatalf("load config: %v", err) } if cfg.Namespace != "soteria" || cfg.SecretNamespace != "soteria" { t.Fatalf("unexpected namespace defaults: %#v", cfg) } if cfg.BackupDriver != defaultBackupDriver || cfg.ResticImage != defaultResticImage { t.Fatalf("unexpected driver/image defaults: %#v", cfg) } if cfg.ResticSecretName != defaultResticSecret || cfg.ListenAddr != defaultListenAddr { t.Fatalf("unexpected restic/listen defaults: %#v", cfg) } if cfg.LonghornURL != defaultLonghornURL || cfg.LonghornBackupMode != defaultLonghornMode { t.Fatalf("unexpected longhorn defaults: %#v", cfg) } if cfg.JobTTLSeconds != defaultJobTTLSeconds { t.Fatalf("expected default ttl %d, got %d", defaultJobTTLSeconds, cfg.JobTTLSeconds) } if cfg.MetricsRefreshInterval != defaultMetricsRefresh || cfg.PolicyEvalInterval != defaultPolicyEval { t.Fatalf("unexpected interval defaults: %#v", cfg) } if cfg.BackupMaxAge != defaultBackupMaxAge { t.Fatalf("unexpected backup max age: %#v", cfg.BackupMaxAge) } if cfg.PolicySecretName != defaultPolicySecret || cfg.UsageSecretName != defaultUsageSecret { t.Fatalf("unexpected secret defaults: %#v", cfg) } if len(cfg.AllowedGroups) != 2 || cfg.AllowedGroups[0] != "admin" || cfg.AllowedGroups[1] != "maintenance" { t.Fatalf("unexpected allowed groups: %#v", cfg.AllowedGroups) } if cfg.JobNodeSelector == nil || len(cfg.JobNodeSelector) != 0 { t.Fatalf("expected empty node selector map, got %#v", cfg.JobNodeSelector) } } func TestLoadSupportsResticAndB2Overrides(t *testing.T) { clearConfigEnv(t) withEnv(t, "SOTERIA_NAMESPACE", "atlas") withEnv(t, "SOTERIA_SECRET_NAMESPACE", "vault") withEnv(t, "SOTERIA_BACKUP_DRIVER", "restic") withEnv(t, "SOTERIA_RESTIC_REPOSITORY", "s3:https://b2.example.invalid/atlas") withEnv(t, "SOTERIA_RESTIC_SECRET_NAME", "restic-creds") withEnv(t, "SOTERIA_RESTIC_BACKUP_ARGS", "--one-file-system --exclude /tmp") withEnv(t, "SOTERIA_RESTIC_FORGET_ARGS", "--keep-last 5 --prune") withEnv(t, "SOTERIA_S3_ENDPOINT", "https://b2.example.invalid") withEnv(t, "SOTERIA_S3_REGION", "us-west-000") withEnv(t, "SOTERIA_JOB_SERVICE_ACCOUNT", "soteria-worker") withEnv(t, "SOTERIA_LISTEN_ADDR", ":9090") withEnv(t, "SOTERIA_JOB_NODE_SELECTOR", "hardware=rpi5,role=worker") withEnv(t, "SOTERIA_AUTH_REQUIRED", "true") withEnv(t, "SOTERIA_ALLOWED_GROUPS", "dev,admin") withEnv(t, "SOTERIA_AUTH_BEARER_TOKENS", "alpha,beta") withEnv(t, "SOTERIA_METRICS_REFRESH_SECONDS", "60") withEnv(t, "SOTERIA_POLICY_EVAL_SECONDS", "120") withEnv(t, "SOTERIA_POLICY_SECRET_NAME", "policy-doc") withEnv(t, "SOTERIA_USAGE_SECRET_NAME", "usage-doc") withEnv(t, "SOTERIA_BACKUP_MAX_AGE_HOURS", "12.5") withEnv(t, "SOTERIA_B2_ENDPOINT", "https://s3.us-west-000.backblazeb2.com") withEnv(t, "SOTERIA_B2_REGION", "us-west-000") withEnv(t, "SOTERIA_B2_BUCKETS", "atlas-a, atlas-b") withEnv(t, "SOTERIA_B2_ACCESS_KEY_ID", "abc") withEnv(t, "SOTERIA_B2_SECRET_ACCESS_KEY", "def") withEnv(t, "SOTERIA_B2_SECRET_NAME", "b2-creds") withEnv(t, "SOTERIA_B2_ACCESS_KEY_FIELD", "ACCESS_KEY") withEnv(t, "SOTERIA_B2_SECRET_KEY_FIELD", "SECRET_KEY") withEnv(t, "SOTERIA_B2_ENDPOINT_FIELD", "ENDPOINT") withEnv(t, "SOTERIA_B2_SCAN_INTERVAL_SECONDS", "900") withEnv(t, "SOTERIA_B2_SCAN_TIMEOUT_SECONDS", "45") withEnv(t, "SOTERIA_JOB_TTL_SECONDS", "7200") cfg, err := Load() if err != nil { t.Fatalf("load config: %v", err) } if cfg.Namespace != "atlas" || cfg.SecretNamespace != "vault" { t.Fatalf("unexpected namespaces: %#v", cfg) } if cfg.BackupDriver != "restic" || cfg.ResticRepository == "" || cfg.ResticSecretName != "restic-creds" { t.Fatalf("unexpected restic config: %#v", cfg) } if strings.Join(cfg.ResticBackupArgs, " ") != "--one-file-system --exclude /tmp" { t.Fatalf("unexpected backup args: %#v", cfg.ResticBackupArgs) } if strings.Join(cfg.ResticForgetArgs, " ") != "--keep-last 5 --prune" { t.Fatalf("unexpected forget args: %#v", cfg.ResticForgetArgs) } if cfg.WorkerServiceAccount != "soteria-worker" || cfg.ListenAddr != ":9090" { t.Fatalf("unexpected worker/listen config: %#v", cfg) } if cfg.JobNodeSelector["hardware"] != "rpi5" || cfg.JobNodeSelector["role"] != "worker" { t.Fatalf("unexpected node selector: %#v", cfg.JobNodeSelector) } if !cfg.AuthRequired || len(cfg.AllowedGroups) != 2 || len(cfg.AuthBearerTokens) != 2 { t.Fatalf("unexpected auth config: %#v", cfg) } if cfg.MetricsRefreshInterval != 60*time.Second || cfg.PolicyEvalInterval != 120*time.Second { t.Fatalf("unexpected intervals: %#v", cfg) } if cfg.BackupMaxAge != time.Duration(12.5*float64(time.Hour)) { t.Fatalf("unexpected backup max age: %#v", cfg.BackupMaxAge) } if !cfg.B2Enabled || cfg.B2SecretNamespace != "atlas" { t.Fatalf("unexpected B2 enablement or secret namespace: %#v", cfg) } if cfg.B2ScanInterval != 900*time.Second || cfg.B2ScanTimeout != 45*time.Second { t.Fatalf("unexpected B2 intervals: %#v", cfg) } if cfg.JobTTLSeconds != 7200 { t.Fatalf("unexpected job ttl: %#v", cfg.JobTTLSeconds) } } func TestLoadInfersB2EnablementAndSecretNamespaceDefault(t *testing.T) { clearConfigEnv(t) withEnv(t, "SOTERIA_NAMESPACE", "atlas") withEnv(t, "SOTERIA_B2_SECRET_NAME", "b2-creds") withEnv(t, "SOTERIA_B2_ACCESS_KEY_ID", "abc") withEnv(t, "SOTERIA_B2_SECRET_ACCESS_KEY", "def") cfg, err := Load() if err != nil { t.Fatalf("load config with inferred b2 enablement: %v", err) } if !cfg.B2Enabled { t.Fatalf("expected B2 enablement to be inferred from secret config") } if cfg.B2SecretNamespace != "atlas" { t.Fatalf("expected B2 secret namespace to default to service namespace, got %#v", cfg.B2SecretNamespace) } } func TestLoadRejectsInvalidConfigurations(t *testing.T) { testCases := []struct { name string env map[string]string substr string }{ { name: "restic missing repository", env: map[string]string{ "SOTERIA_BACKUP_DRIVER": "restic", "SOTERIA_RESTIC_REPOSITORY": "", }, substr: "SOTERIA_RESTIC_REPOSITORY is required", }, { name: "invalid node selector", env: map[string]string{ "SOTERIA_JOB_NODE_SELECTOR": "broken-selector", }, substr: "SOTERIA_JOB_NODE_SELECTOR is invalid", }, { name: "restic invalid repository path", env: map[string]string{ "SOTERIA_BACKUP_DRIVER": "restic", "SOTERIA_RESTIC_REPOSITORY": "s3:https://repo/../bad", "SOTERIA_RESTIC_SECRET_NAME": "restic-creds", }, substr: "SOTERIA_RESTIC_REPOSITORY contains invalid path segments", }, { name: "unsupported driver", env: map[string]string{ "SOTERIA_BACKUP_DRIVER": "zfs", }, substr: "SOTERIA_BACKUP_DRIVER must be longhorn or restic", }, { name: "invalid longhorn mode", env: map[string]string{ "SOTERIA_LONGHORN_BACKUP_MODE": "delta", }, substr: "SOTERIA_LONGHORN_BACKUP_MODE must be incremental or full", }, { name: "invalid metrics interval", env: map[string]string{ "SOTERIA_METRICS_REFRESH_SECONDS": "0", }, substr: "SOTERIA_METRICS_REFRESH_SECONDS must be greater than zero", }, { name: "invalid backup max age", env: map[string]string{ "SOTERIA_BACKUP_MAX_AGE_HOURS": "0", }, substr: "SOTERIA_BACKUP_MAX_AGE_HOURS must be greater than zero", }, { name: "b2 enabled without endpoint or secret", env: map[string]string{ "SOTERIA_B2_ENABLED": "true", }, substr: "SOTERIA_B2_ENDPOINT or SOTERIA_B2_SECRET_NAME is required", }, { name: "invalid b2 scan interval", env: map[string]string{ "SOTERIA_B2_ENABLED": "true", "SOTERIA_B2_ENDPOINT": "https://s3.example.invalid", "SOTERIA_B2_SCAN_INTERVAL_SECONDS": "0", }, substr: "SOTERIA_B2_SCAN_INTERVAL_SECONDS must be greater than zero", }, { name: "invalid b2 scan timeout", env: map[string]string{ "SOTERIA_B2_ENABLED": "true", "SOTERIA_B2_ENDPOINT": "https://s3.example.invalid", "SOTERIA_B2_SCAN_TIMEOUT_SECONDS": "0", }, substr: "SOTERIA_B2_SCAN_TIMEOUT_SECONDS must be greater than zero", }, { name: "invalid policy eval interval", env: map[string]string{ "SOTERIA_POLICY_EVAL_SECONDS": "0", }, substr: "SOTERIA_POLICY_EVAL_SECONDS must be greater than zero", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { clearConfigEnv(t) for key, value := range tc.env { withEnv(t, key, value) } _, err := Load() if err == nil || !strings.Contains(err.Error(), tc.substr) { t.Fatalf("expected error containing %q, got %v", tc.substr, err) } }) } } func TestEnvAndParsingHelpers(t *testing.T) { clearConfigEnv(t) withEnv(t, "TEST_STRING", " value ") withEnv(t, "TEST_INT", "42") withEnv(t, "TEST_FLOAT", "1.5") withEnv(t, "TEST_BOOL_TRUE", "yes") withEnv(t, "TEST_BOOL_FALSE", "off") if got := getenv("TEST_STRING"); got != "value" { t.Fatalf("unexpected getenv result %q", got) } if got := getenvDefault("MISSING_STRING", "fallback"); got != "fallback" { t.Fatalf("unexpected getenvDefault result %q", got) } if got, ok := getenvInt("TEST_INT"); !ok || got != 42 { t.Fatalf("unexpected getenvInt result %d %v", got, ok) } if got, ok := getenvInt("TEST_STRING"); ok || got != 0 { t.Fatalf("expected invalid int parse to fail, got %d %v", got, ok) } if got, ok := getenvFloat("TEST_FLOAT"); !ok || got != 1.5 { t.Fatalf("unexpected getenvFloat result %f %v", got, ok) } if got, ok := getenvFloat("TEST_STRING"); ok || got != 0 { t.Fatalf("expected invalid float parse to fail, got %f %v", got, ok) } if !getenvBool("TEST_BOOL_TRUE") || getenvBool("TEST_BOOL_FALSE") { t.Fatalf("unexpected getenvBool results") } if selector := parseNodeSelector("role=worker, hardware=rpi5"); selector["role"] != "worker" || selector["hardware"] != "rpi5" { t.Fatalf("unexpected selector parse: %#v", selector) } if selector := parseNodeSelector("role=worker, , hardware=rpi5"); selector["role"] != "worker" || selector["hardware"] != "rpi5" { t.Fatalf("expected empty selector segments to be ignored, got %#v", selector) } if parseNodeSelector("role=") != nil { t.Fatalf("expected invalid selector to return nil") } if values := parseCSV("one, two, , three"); len(values) != 3 || values[1] != "two" { t.Fatalf("unexpected CSV parse: %#v", values) } if values := parseCSV(" "); values != nil { t.Fatalf("expected blank CSV to return nil, got %#v", values) } }