From db5c0152ea227f527c88391f7688a295c67f80a8 Mon Sep 17 00:00:00 2001 From: Lewis Injai Date: Wed, 27 May 2026 15:39:01 +0300 Subject: [PATCH 1/4] setup: add Viper config loading and internal package structure --- cmd/server/main.go | 32 ++++++- go.mod | 17 ++++ go.sum | 47 ++++++++++ internal/config/.gitkeep | 0 internal/config/config.go | 155 +++++++++++++++++++++++++++++++++ internal/config/config_test.go | 83 ++++++++++++++++++ 6 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 go.sum delete mode 100644 internal/config/.gitkeep create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 3381a93..c7efd55 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,7 +1,35 @@ package main -import "fmt" +import ( + "fmt" + "log/slog" + "os" + + "github.com/ratifydata/ratify/internal/config" +) func main() { - fmt.Println("Ratify server starting...") + // Load configuration from environment variables and .env file. + cfg, err := config.Load() + if err != nil { + slog.Error("failed to load configuration", "error", err) + os.Exit(1) + } + + // Structured JSON logging — standard for production services. + // In development, this is readable. In production, log + // aggregation tools (Datadog, Loki) can parse it. + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + slog.SetDefault(logger) + + slog.Info("Ratify server starting", + "port", cfg.Port, + "environment", cfg.Environment, + "breach_interval", cfg.BreachDetectionInterval, + ) + + // Placeholder — the real server implementation would go here. + fmt.Printf("Ratify server ready on port %d\n", cfg.Port) } diff --git a/go.mod b/go.mod index 6630c9e..0660b65 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,20 @@ module github.com/ratifydata/ratify go 1.24 + +require github.com/spf13/viper v1.21.0 + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..81f59c4 --- /dev/null +++ b/go.sum @@ -0,0 +1,47 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/.gitkeep b/internal/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..81d33a2 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,155 @@ +package config + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/viper" +) + +// Config holds all configuration for the Ratify server. +// Values are loaded from environment variables or a .env file. +// No configuration value is hardcoded anywhere in the application. +type Config struct { + // Server + Port int `mapstructure:"PORT"` + Environment string `mapstructure:"ENVIRONMENT"` + + // Database + // This is Ratify's own metadata database — not the databases + // it monitors. It stores contracts, proposals, audit logs, + // and encrypted credentials. + DatabaseURL string `mapstructure:"DATABASE_URL"` + + // Security + // EncryptionKey is used to encrypt database credentials at + // rest using AES-256-GCM. Must be exactly 32 bytes (64 hex chars). + EncryptionKey string `mapstructure:"ENCRYPTION_KEY"` + + // JWTSecret is used to sign and verify session tokens for + // the web UI. Must be kept secret. + JWTSecret string `mapstructure:"JWT_SECRET"` + + // Email (SMTP) + SMTPHost string `mapstructure:"SMTP_HOST"` + SMTPPort int `mapstructure:"SMTP_PORT"` + SMTPUsername string `mapstructure:"SMTP_USERNAME"` + SMTPPassword string `mapstructure:"SMTP_PASSWORD"` + SMTPFrom string `mapstructure:"SMTP_FROM"` + + // Breach detection + // How often Ratify compares live schemas against active contracts. + // Parsed as a Go duration string: "1h", "30m", "24h". + BreachDetectionInterval string `mapstructure:"BREACH_DETECTION_INTERVAL"` +} + +// Load reads configuration from environment variables and the +// .env file (if present). Environment variables take priority +// over .env file values. +// +// Returns an error if any required variable is missing or invalid. +func Load() (*Config, error) { + v := viper.New() + + // Tell Viper to look for a .env file in the current directory. + // If no .env file exists, Viper falls back to environment + // variables and defaults — it does not error. + v.SetConfigFile(".env") + v.SetConfigType("env") + + // Read the .env file. Ignore the error if the file does not + // exist — this is expected in Docker and production environments + // where variables are injected directly. + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + // The file exists but could not be read — that is a + // real error worth reporting. + _ = ok // file simply does not exist, continue + } + } + + // Tell Viper to also read from real environment variables. + // Environment variables override .env file values. + v.AutomaticEnv() + + // Set defaults for every variable. + // These are used when no value is provided in .env or the + // environment. Safe values that work for local development. + v.SetDefault("PORT", 8080) + v.SetDefault("ENVIRONMENT", "development") + v.SetDefault("DATABASE_URL", "postgresql://ratify:ratify@localhost:5432/ratify?sslmode=disable") + v.SetDefault("ENCRYPTION_KEY", strings.Repeat("0", 64)) + v.SetDefault("JWT_SECRET", "local-dev-jwt-secret-change-in-production") + v.SetDefault("SMTP_HOST", "smtp.mailtrap.io") + v.SetDefault("SMTP_PORT", 587) + v.SetDefault("SMTP_USERNAME", "") + v.SetDefault("SMTP_PASSWORD", "") + v.SetDefault("SMTP_FROM", "ratify@example.com") + v.SetDefault("BREACH_DETECTION_INTERVAL", "1h") + + // Unmarshal all values into the Config struct. + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + // Validate required fields that have no safe default. + if err := validate(&cfg); err != nil { + return nil, err + } + + return &cfg, nil +} + +// IsProduction returns true when the application is running in +// production. Used to enable stricter security settings. +func (c *Config) IsProduction() bool { + return c.Environment == "production" +} + +// IsDevelopment returns true when the application is running +// in development mode. +func (c *Config) IsDevelopment() bool { + return c.Environment == "development" +} + +// BreachInterval parses the BreachDetectionInterval string into +// a time.Duration. Returns the parsed duration or an error if +// the string is not a valid Go duration. +func (c *Config) BreachInterval() (time.Duration, error) { + d, err := time.ParseDuration(c.BreachDetectionInterval) + if err != nil { + return 0, fmt.Errorf( + "invalid BREACH_DETECTION_INTERVAL %q: must be a valid Go duration (e.g. 1h, 30m): %w", + c.BreachDetectionInterval, + err, + ) + } + return d, nil +} + +// validate checks that required configuration values are present +// and correctly formatted. Returns a descriptive error for the +// first problem found. +func validate(cfg *Config) error { + if cfg.DatabaseURL == "" { + return fmt.Errorf("DATABASE_URL is required") + } + if len(cfg.EncryptionKey) != 64 { + return fmt.Errorf( + "ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes), got %d characters", + len(cfg.EncryptionKey), + ) + } + if cfg.JWTSecret == "" { + return fmt.Errorf("JWT_SECRET is required") + } + if cfg.Port < 1 || cfg.Port > 65535 { + return fmt.Errorf( + "PORT must be between 1 and 65535, got %d", + cfg.Port, + ) + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..634ff6f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,83 @@ +package config + +import ( + "os" + "testing" +) + +func TestLoad_Defaults(t *testing.T) { + // Load with no .env file and no environment variables set. + // Should succeed using defaults. + cfg, err := Load() + if err != nil { + t.Fatalf("Load() with defaults failed: %v", err) + } + + if cfg.Port != 8080 { + t.Errorf("expected default PORT 8080, got %d", cfg.Port) + } + + if cfg.Environment != "development" { + t.Errorf("expected default ENVIRONMENT 'development', got %q", cfg.Environment) + } +} + +func TestLoad_EnvironmentVariableOverridesDefault(t *testing.T) { + // Set an environment variable and confirm it overrides the default. + os.Setenv("PORT", "9090") + defer os.Unsetenv("PORT") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + if cfg.Port != 9090 { + t.Errorf("expected PORT 9090 from environment, got %d", cfg.Port) + } +} + +func TestLoad_InvalidEncryptionKey(t *testing.T) { + // Set an encryption key that is too short. + // Load() should return an error. + os.Setenv("ENCRYPTION_KEY", "tooshort") + defer os.Unsetenv("ENCRYPTION_KEY") + + _, err := Load() + if err == nil { + t.Fatal("expected error for short ENCRYPTION_KEY, got nil") + } +} + +func TestIsProduction(t *testing.T) { + cfg := &Config{Environment: "production"} + if !cfg.IsProduction() { + t.Error("expected IsProduction() to return true") + } +} + +func TestIsDevelopment(t *testing.T) { + cfg := &Config{Environment: "development"} + if !cfg.IsDevelopment() { + t.Error("expected IsDevelopment() to return true") + } +} + +func TestBreachInterval_Valid(t *testing.T) { + cfg := &Config{BreachDetectionInterval: "1h"} + d, err := cfg.BreachInterval() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if d.Hours() != 1 { + t.Errorf("expected 1 hour, got %v", d) + } +} + +func TestBreachInterval_Invalid(t *testing.T) { + cfg := &Config{BreachDetectionInterval: "not-a-duration"} + _, err := cfg.BreachInterval() + if err == nil { + t.Fatal("expected error for invalid duration, got nil") + } +} From 0d3bb44d989b7d54ca2aaf27c9d55955ba07a881 Mon Sep 17 00:00:00 2001 From: Lewis Injai Date: Thu, 28 May 2026 10:14:29 +0300 Subject: [PATCH 2/4] fix: handle os.Setenv errors in tests and upgrade to Go 1.25 --- .github/workflows/ci.yml | 2 +- go.mod | 2 +- internal/config/config_test.go | 23 ++++++++++++++++------- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cad9e7f..7e8d662 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ concurrency: # Define versions once here. When upgrading Go or Node, change # the value here and it applies to every job automatically. env: - GO_VERSION: '1.24' + GO_VERSION: '1.25' NODE_VERSION: '20' GOLANGCI_LINT_VERSION: 'v2.1.6' MIGRATE_VERSION: 'v4.17.0' diff --git a/go.mod b/go.mod index 0660b65..50d5d76 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ratifydata/ratify -go 1.24 +go 1.25 require github.com/spf13/viper v1.21.0 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 634ff6f..8ed6855 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -23,9 +23,14 @@ func TestLoad_Defaults(t *testing.T) { } func TestLoad_EnvironmentVariableOverridesDefault(t *testing.T) { - // Set an environment variable and confirm it overrides the default. - os.Setenv("PORT", "9090") - defer os.Unsetenv("PORT") + if err := os.Setenv("PORT", "9090"); err != nil { + t.Fatalf("failed to set env var: %v", err) + } + defer func() { + if err := os.Unsetenv("PORT"); err != nil { + t.Fatalf("failed to unset env var: %v", err) + } + }() cfg, err := Load() if err != nil { @@ -38,10 +43,14 @@ func TestLoad_EnvironmentVariableOverridesDefault(t *testing.T) { } func TestLoad_InvalidEncryptionKey(t *testing.T) { - // Set an encryption key that is too short. - // Load() should return an error. - os.Setenv("ENCRYPTION_KEY", "tooshort") - defer os.Unsetenv("ENCRYPTION_KEY") + if err := os.Setenv("ENCRYPTION_KEY", "tooshort"); err != nil { + t.Fatalf("failed to set env var: %v", err) + } + defer func() { + if err := os.Unsetenv("ENCRYPTION_KEY"); err != nil { + t.Fatalf("failed to unset env var: %v", err) + } + }() _, err := Load() if err == nil { From fc88441dfa2f66086c60c63941c5a37d422379f8 Mon Sep 17 00:00:00 2001 From: Lewis Injai Date: Thu, 28 May 2026 10:38:36 +0300 Subject: [PATCH 3/4] ci: bump golangci-lint version to support Go 1.25 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e8d662..512e043 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ concurrency: env: GO_VERSION: '1.25' NODE_VERSION: '20' - GOLANGCI_LINT_VERSION: 'v2.1.6' + GOLANGCI_LINT_VERSION: 'v2.4.0' MIGRATE_VERSION: 'v4.17.0' COVERAGE_THRESHOLD: '0' From 68fe940e76f86f9d347ef82904885bd6ab205d11 Mon Sep 17 00:00:00 2001 From: Lewis Injai Date: Thu, 28 May 2026 13:27:28 +0300 Subject: [PATCH 4/4] fix: isolate config tests safely using t.Cleanup and correct interval duration format --- .github/workflows/ci.yml | 2 +- internal/config/config_test.go | 57 +++++++++++++++++++++++++++------- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 512e043..c77d2e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,7 +139,7 @@ jobs: ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000 JWT_SECRET: ci-test-jwt-secret-not-for-production ENVIRONMENT: test - BREACH_DETECTION_INTERVAL: '@every 1h' + BREACH_DETECTION_INTERVAL: '1h' # Read the total coverage percentage and fail the build if # it is below the threshold defined in the env block above. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8ed6855..e01bad4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -5,9 +5,48 @@ import ( "testing" ) +// clearConfigEnv unsets configuration environment variables for the scope of the test. +// It backs up original values and restores them afterward using t.Cleanup to prevent +// leaking side effects into downstream tests (e.g., breaking database connections). +func clearConfigEnv(t *testing.T) { + t.Helper() + vars := []string{ + "PORT", "ENVIRONMENT", "DATABASE_URL", + "ENCRYPTION_KEY", "JWT_SECRET", + "SMTP_HOST", "SMTP_PORT", "SMTP_USERNAME", "SMTP_PASSWORD", "SMTP_FROM", + "BREACH_DETECTION_INTERVAL", + } + + // 1. Capture and back up current environment states + originalValues := make(map[string]string) + for _, v := range vars { + if val, exists := os.LookupEnv(v); exists { + originalValues[v] = val + } + } + + // 2. Clear out the environment variables for this test context + for _, v := range vars { + if err := os.Unsetenv(v); err != nil { + t.Fatalf("failed to unset %s: %v", v, err) + } + } + + // 3. Register a cleanup hook to seamlessly restore original state when this test exits + t.Cleanup(func() { + for _, v := range vars { + if originalVal, wasSet := originalValues[v]; wasSet { + _ = os.Setenv(v, originalVal) + } else { + _ = os.Unsetenv(v) + } + } + }) +} + func TestLoad_Defaults(t *testing.T) { - // Load with no .env file and no environment variables set. - // Should succeed using defaults. + clearConfigEnv(t) + cfg, err := Load() if err != nil { t.Fatalf("Load() with defaults failed: %v", err) @@ -23,14 +62,11 @@ func TestLoad_Defaults(t *testing.T) { } func TestLoad_EnvironmentVariableOverridesDefault(t *testing.T) { + clearConfigEnv(t) + if err := os.Setenv("PORT", "9090"); err != nil { t.Fatalf("failed to set env var: %v", err) } - defer func() { - if err := os.Unsetenv("PORT"); err != nil { - t.Fatalf("failed to unset env var: %v", err) - } - }() cfg, err := Load() if err != nil { @@ -43,14 +79,11 @@ func TestLoad_EnvironmentVariableOverridesDefault(t *testing.T) { } func TestLoad_InvalidEncryptionKey(t *testing.T) { + clearConfigEnv(t) + if err := os.Setenv("ENCRYPTION_KEY", "tooshort"); err != nil { t.Fatalf("failed to set env var: %v", err) } - defer func() { - if err := os.Unsetenv("ENCRYPTION_KEY"); err != nil { - t.Fatalf("failed to unset env var: %v", err) - } - }() _, err := Load() if err == nil {