From 2ec2edae4cf9cee00916f0ac3b13e179d1804434 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 20 Apr 2026 17:28:18 -0300 Subject: [PATCH] test(config): raise environment loader coverage --- internal/config/config_test.go | 308 +++++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 internal/config/config_test.go diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..4b561c6 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,308 @@ +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) + + 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 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: "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 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 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) + } +}