package config import ( "sync" "testing" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // resetConfigState resets the package-level singleton so each test starts fresh. func resetConfigState() { cfg = nil cfgOnce = sync.Once{} viper.Reset() } func TestLoad_DefaultValues(t *testing.T) { resetConfigState() // Provide required SECRET_KEY so validation passes t.Setenv("SECRET_KEY", "a-strong-secret-key-for-tests") c, err := Load() require.NoError(t, err) // Server defaults assert.Equal(t, 8000, c.Server.Port) assert.False(t, c.Server.Debug) assert.False(t, c.Server.DebugFixedCodes) assert.Equal(t, "UTC", c.Server.Timezone) assert.Equal(t, "/app/static", c.Server.StaticDir) assert.Equal(t, "https://api.myhoneydue.com", c.Server.BaseURL) // Database defaults assert.Equal(t, "localhost", c.Database.Host) assert.Equal(t, 5432, c.Database.Port) assert.Equal(t, "postgres", c.Database.User) assert.Equal(t, "honeydue", c.Database.Database) assert.Equal(t, "disable", c.Database.SSLMode) assert.Equal(t, 25, c.Database.MaxOpenConns) assert.Equal(t, 10, c.Database.MaxIdleConns) // Redis defaults assert.Equal(t, "redis://localhost:6379/0", c.Redis.URL) assert.Equal(t, 0, c.Redis.DB) // Worker defaults assert.Equal(t, 14, c.Worker.TaskReminderHour) assert.Equal(t, 15, c.Worker.OverdueReminderHour) assert.Equal(t, 3, c.Worker.DailyNotifHour) // Token expiry defaults assert.Equal(t, 90, c.Security.TokenExpiryDays) assert.Equal(t, 60, c.Security.TokenRefreshDays) // Feature flags default to true assert.True(t, c.Features.PushEnabled) assert.True(t, c.Features.EmailEnabled) assert.True(t, c.Features.WebhooksEnabled) assert.True(t, c.Features.OnboardingEmailsEnabled) assert.True(t, c.Features.PDFReportsEnabled) assert.True(t, c.Features.WorkerEnabled) } func TestLoad_EnvOverrides(t *testing.T) { resetConfigState() t.Setenv("SECRET_KEY", "a-strong-secret-key-for-tests") t.Setenv("PORT", "9090") t.Setenv("DEBUG", "true") t.Setenv("DB_HOST", "db.example.com") t.Setenv("DB_PORT", "5433") t.Setenv("TOKEN_EXPIRY_DAYS", "180") t.Setenv("TOKEN_REFRESH_DAYS", "120") t.Setenv("FEATURE_PUSH_ENABLED", "false") c, err := Load() require.NoError(t, err) assert.Equal(t, 9090, c.Server.Port) assert.True(t, c.Server.Debug) assert.Equal(t, "db.example.com", c.Database.Host) assert.Equal(t, 5433, c.Database.Port) assert.Equal(t, 180, c.Security.TokenExpiryDays) assert.Equal(t, 120, c.Security.TokenRefreshDays) assert.False(t, c.Features.PushEnabled) } func TestLoad_Validation_MissingSecretKey_Production(t *testing.T) { // Test validate() directly to avoid the sync.Once mutex issue // that occurs when Load() resets cfgOnce inside cfgOnce.Do() cfg := &Config{ Server: ServerConfig{Debug: false}, Security: SecurityConfig{SecretKey: ""}, } err := validate(cfg) require.Error(t, err) assert.Contains(t, err.Error(), "SECRET_KEY") } func TestLoad_Validation_MissingSecretKey_DebugMode(t *testing.T) { resetConfigState() t.Setenv("SECRET_KEY", "") t.Setenv("DEBUG", "true") c, err := Load() require.NoError(t, err) // In debug mode, a default key is assigned assert.Equal(t, "change-me-in-production-secret-key-12345", c.Security.SecretKey) } func TestLoad_Validation_WeakSecretKey_Production(t *testing.T) { // Test validate() directly to avoid the sync.Once mutex issue cfg := &Config{ Server: ServerConfig{Debug: false}, Security: SecurityConfig{SecretKey: "password"}, } err := validate(cfg) require.Error(t, err) assert.Contains(t, err.Error(), "well-known weak value") } func TestLoad_Validation_WeakSecretKey_DebugMode(t *testing.T) { resetConfigState() t.Setenv("SECRET_KEY", "secret") t.Setenv("DEBUG", "true") // In debug mode, weak keys produce a warning but no error c, err := Load() require.NoError(t, err) assert.Equal(t, "secret", c.Security.SecretKey) } func TestLoad_Validation_EncryptionKey_Valid(t *testing.T) { resetConfigState() t.Setenv("SECRET_KEY", "a-strong-secret-key-for-tests") // Valid 64-char hex key (32 bytes) t.Setenv("STORAGE_ENCRYPTION_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") c, err := Load() require.NoError(t, err) assert.Equal(t, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", c.Storage.EncryptionKey) } func TestLoad_Validation_EncryptionKey_WrongLength(t *testing.T) { // Test validate() directly to avoid the sync.Once mutex issue cfg := &Config{ Server: ServerConfig{Debug: false}, Security: SecurityConfig{SecretKey: "a-strong-secret-key-for-tests"}, Storage: StorageConfig{EncryptionKey: "tooshort"}, } err := validate(cfg) require.Error(t, err) assert.Contains(t, err.Error(), "STORAGE_ENCRYPTION_KEY must be exactly 64 hex characters") } func TestLoad_Validation_EncryptionKey_InvalidHex(t *testing.T) { // Test validate() directly to avoid the sync.Once mutex issue cfg := &Config{ Server: ServerConfig{Debug: false}, Security: SecurityConfig{SecretKey: "a-strong-secret-key-for-tests"}, Storage: StorageConfig{EncryptionKey: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"}, } err := validate(cfg) require.Error(t, err) assert.Contains(t, err.Error(), "invalid hex") } func TestLoad_DatabaseURL_Override(t *testing.T) { resetConfigState() t.Setenv("SECRET_KEY", "a-strong-secret-key-for-tests") t.Setenv("DATABASE_URL", "postgres://myuser:mypass@dbhost:5433/mydb?sslmode=require") c, err := Load() require.NoError(t, err) assert.Equal(t, "dbhost", c.Database.Host) assert.Equal(t, 5433, c.Database.Port) assert.Equal(t, "myuser", c.Database.User) assert.Equal(t, "mypass", c.Database.Password) assert.Equal(t, "mydb", c.Database.Database) assert.Equal(t, "require", c.Database.SSLMode) } func TestDSN(t *testing.T) { d := DatabaseConfig{ Host: "localhost", Port: 5432, User: "testuser", Password: "Password123", Database: "testdb", SSLMode: "disable", } dsn := d.DSN() assert.Contains(t, dsn, "host=localhost") assert.Contains(t, dsn, "port=5432") assert.Contains(t, dsn, "user=testuser") assert.Contains(t, dsn, "password=Password123") assert.Contains(t, dsn, "dbname=testdb") assert.Contains(t, dsn, "sslmode=disable") } func TestMaskURLCredentials(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "URL with password", input: "postgres://user:secret@host:5432/db", expected: "postgres://user:xxxxx@host:5432/db", }, { name: "URL without password", input: "postgres://user@host:5432/db", expected: "postgres://user@host:5432/db", }, { name: "URL without user info", input: "postgres://host:5432/db", expected: "postgres://host:5432/db", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := MaskURLCredentials(tc.input) assert.Equal(t, tc.expected, result) }) } } func TestParseCorsOrigins(t *testing.T) { tests := []struct { name string input string expected []string }{ {"empty string", "", nil}, {"single origin", "https://example.com", []string{"https://example.com"}}, {"multiple origins", "https://a.com, https://b.com", []string{"https://a.com", "https://b.com"}}, {"whitespace trimmed", " https://a.com , https://b.com ", []string{"https://a.com", "https://b.com"}}, {"empty parts skipped", "https://a.com,,https://b.com", []string{"https://a.com", "https://b.com"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result := parseCorsOrigins(tc.input) assert.Equal(t, tc.expected, result) }) } } func TestParseDatabaseURL(t *testing.T) { tests := []struct { name string url string wantHost string wantPort int wantUser string wantPass string wantDB string wantSSL string expectError bool }{ { name: "full URL", url: "postgres://user:Password123@host:5433/mydb?sslmode=require", wantHost: "host", wantPort: 5433, wantUser: "user", wantPass: "Password123", wantDB: "mydb", wantSSL: "require", }, { name: "default port", url: "postgres://user:pass@host/mydb", wantHost: "host", wantPort: 5432, wantUser: "user", wantPass: "pass", wantDB: "mydb", wantSSL: "", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { result, err := parseDatabaseURL(tc.url) if tc.expectError { require.Error(t, err) return } require.NoError(t, err) assert.Equal(t, tc.wantHost, result.Host) assert.Equal(t, tc.wantPort, result.Port) assert.Equal(t, tc.wantUser, result.User) assert.Equal(t, tc.wantPass, result.Password) assert.Equal(t, tc.wantDB, result.Database) assert.Equal(t, tc.wantSSL, result.SSLMode) }) } } func TestIsWeakSecretKey(t *testing.T) { assert.True(t, isWeakSecretKey("secret")) assert.True(t, isWeakSecretKey("Secret")) // case-insensitive assert.True(t, isWeakSecretKey(" changeme ")) // whitespace trimmed assert.True(t, isWeakSecretKey("password")) assert.True(t, isWeakSecretKey("change-me")) assert.False(t, isWeakSecretKey("a-strong-unique-production-key")) } func TestGet_ReturnsNilBeforeLoad(t *testing.T) { resetConfigState() assert.Nil(t, Get()) }