soteria/internal/config/config_test.go

350 lines
12 KiB
Go
Raw Normal View History

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)
}
}