diff --git a/backend/internal/common/env_config.go b/backend/internal/common/env_config.go index 041f1b09..ce213fb1 100644 --- a/backend/internal/common/env_config.go +++ b/backend/internal/common/env_config.go @@ -32,17 +32,17 @@ const ( ) type EnvConfigSchema struct { - AppEnv string `env:"APP_ENV"` - LogLevel string `env:"LOG_LEVEL"` - AppURL string `env:"APP_URL"` - DbProvider DbProvider `env:"DB_PROVIDER"` + AppEnv string `env:"APP_ENV" options:"toLower"` + LogLevel string `env:"LOG_LEVEL" options:"toLower"` + AppURL string `env:"APP_URL" options:"toLower"` + DbProvider DbProvider `env:"DB_PROVIDER" options:"toLower"` DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"` UploadPath string `env:"UPLOAD_PATH"` KeysPath string `env:"KEYS_PATH"` KeysStorage string `env:"KEYS_STORAGE"` EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"` Port string `env:"PORT"` - Host string `env:"HOST"` + Host string `env:"HOST" options:"toLower"` UnixSocket string `env:"UNIX_SOCKET"` UnixSocketMode string `env:"UNIX_SOCKET_MODE"` MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"` @@ -112,31 +112,40 @@ func parseEnvConfig() error { return fmt.Errorf("error parsing env config: %w", err) } - err = resolveFileBasedEnvVariables(&EnvConfig) + err = prepareEnvConfig(&EnvConfig) + if err != nil { + return fmt.Errorf("error preparing env config: %w", err) + } + + err = validateEnvConfig(&EnvConfig) if err != nil { return err } - // Validate the environment variables - EnvConfig.LogLevel = strings.ToLower(EnvConfig.LogLevel) - if _, err := sloggin.ParseLevel(EnvConfig.LogLevel); err != nil { + return nil + +} + +// validateEnvConfig checks the EnvConfig for required fields and valid values +func validateEnvConfig(config *EnvConfigSchema) error { + if _, err := sloggin.ParseLevel(config.LogLevel); err != nil { return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'") } - switch EnvConfig.DbProvider { + switch config.DbProvider { case DbProviderSqlite: - if EnvConfig.DbConnectionString == "" { - EnvConfig.DbConnectionString = defaultSqliteConnString + if config.DbConnectionString == "" { + config.DbConnectionString = defaultSqliteConnString } case DbProviderPostgres: - if EnvConfig.DbConnectionString == "" { + if config.DbConnectionString == "" { return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database") } default: return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'") } - parsedAppUrl, err := url.Parse(EnvConfig.AppURL) + parsedAppUrl, err := url.Parse(config.AppURL) if err != nil { return errors.New("APP_URL is not a valid URL") } @@ -145,10 +154,10 @@ func parseEnvConfig() error { } // Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided - if EnvConfig.InternalAppURL == "" { - EnvConfig.InternalAppURL = EnvConfig.AppURL + if config.InternalAppURL == "" { + config.InternalAppURL = config.AppURL } else { - parsedInternalAppUrl, err := url.Parse(EnvConfig.InternalAppURL) + parsedInternalAppUrl, err := url.Parse(config.InternalAppURL) if err != nil { return errors.New("INTERNAL_APP_URL is not a valid URL") } @@ -157,25 +166,26 @@ func parseEnvConfig() error { } } - switch EnvConfig.KeysStorage { + switch config.KeysStorage { // KeysStorage defaults to "file" if empty case "": - EnvConfig.KeysStorage = "file" + config.KeysStorage = "file" case "database": - if EnvConfig.EncryptionKey == nil { + if config.EncryptionKey == nil { return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database") } case "file": // All good, these are valid values default: - return fmt.Errorf("invalid value for KEYS_STORAGE: %s", EnvConfig.KeysStorage) + return fmt.Errorf("invalid value for KEYS_STORAGE: %s", config.KeysStorage) } return nil + } -// resolveFileBasedEnvVariables uses reflection to automatically resolve file-based secrets -func resolveFileBasedEnvVariables(config *EnvConfigSchema) error { +// prepareEnvConfig processes special options for EnvConfig fields +func prepareEnvConfig(config *EnvConfigSchema) error { val := reflect.ValueOf(config).Elem() typ := val.Type() @@ -183,48 +193,65 @@ func resolveFileBasedEnvVariables(config *EnvConfigSchema) error { field := val.Field(i) fieldType := typ.Field(i) - // Only process string and []byte fields - isString := field.Kind() == reflect.String - isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8 - if !isString && !isByteSlice { - continue - } - - // Only process fields with the "options" tag set to "file" optionsTag := fieldType.Tag.Get("options") - if optionsTag != "file" { - continue - } + options := strings.Split(optionsTag, ",") - // Only process fields with the "env" tag - envTag := fieldType.Tag.Get("env") - if envTag == "" { - continue - } - - envVarName := envTag - if commaIndex := len(envTag); commaIndex > 0 { - envVarName = envTag[:commaIndex] - } - - // If the file environment variable is not set, skip - envVarFileName := envVarName + "_FILE" - envVarFileValue := os.Getenv(envVarFileName) - if envVarFileValue == "" { - continue - } - - fileContent, err := os.ReadFile(envVarFileValue) - if err != nil { - return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err) - } - - if isString { - field.SetString(strings.TrimSpace(string(fileContent))) - } else { - field.SetBytes(fileContent) + for _, option := range options { + switch option { + case "toLower": + if field.Kind() == reflect.String { + field.SetString(strings.ToLower(field.String())) + } + case "file": + err := resolveFileBasedEnvVariable(field, fieldType) + if err != nil { + return err + } + } } } return nil } + +// resolveFileBasedEnvVariable checks if an environment variable with the suffix "_FILE" is set, +// reads the content of the file specified by that variable, and sets the corresponding field's value. +func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructField) error { + // Only process string and []byte fields + isString := field.Kind() == reflect.String + isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8 + if !isString && !isByteSlice { + return nil + } + + // Only process fields with the "env" tag + envTag := fieldType.Tag.Get("env") + if envTag == "" { + return nil + } + + envVarName := envTag + if commaIndex := len(envTag); commaIndex > 0 { + envVarName = envTag[:commaIndex] + } + + // If the file environment variable is not set, skip + envVarFileName := envVarName + "_FILE" + envVarFileValue := os.Getenv(envVarFileName) + if envVarFileValue == "" { + return nil + } + + fileContent, err := os.ReadFile(envVarFileValue) + if err != nil { + return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err) + } + + if isString { + field.SetString(strings.TrimSpace(string(fileContent))) + } else { + field.SetBytes(fileContent) + } + + return nil +} diff --git a/backend/internal/common/env_config_test.go b/backend/internal/common/env_config_test.go index b192adcd..ef754182 100644 --- a/backend/internal/common/env_config_test.go +++ b/backend/internal/common/env_config_test.go @@ -17,18 +17,19 @@ func TestParseEnvConfig(t *testing.T) { t.Run("should parse valid SQLite config correctly", func(t *testing.T) { EnvConfig = defaultConfig() - t.Setenv("DB_PROVIDER", "sqlite") + t.Setenv("DB_PROVIDER", "SQLITE") // should be lowercased automatically t.Setenv("DB_CONNECTION_STRING", "file:test.db") - t.Setenv("APP_URL", "http://localhost:3000") + t.Setenv("APP_URL", "HTTP://LOCALHOST:3000") err := parseEnvConfig() require.NoError(t, err) assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider) + assert.Equal(t, "http://localhost:3000", EnvConfig.AppURL) }) t.Run("should parse valid Postgres config correctly", func(t *testing.T) { EnvConfig = defaultConfig() - t.Setenv("DB_PROVIDER", "postgres") + t.Setenv("DB_PROVIDER", "POSTGRES") t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db") t.Setenv("APP_URL", "https://example.com") @@ -51,7 +52,6 @@ func TestParseEnvConfig(t *testing.T) { t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) { EnvConfig = defaultConfig() t.Setenv("DB_PROVIDER", "sqlite") - t.Setenv("DB_CONNECTION_STRING", "") // Explicitly empty t.Setenv("APP_URL", "http://localhost:3000") err := parseEnvConfig() @@ -192,25 +192,25 @@ func TestParseEnvConfig(t *testing.T) { t.Setenv("DB_PROVIDER", "postgres") t.Setenv("DB_CONNECTION_STRING", "postgres://test") t.Setenv("APP_URL", "https://prod.example.com") - t.Setenv("APP_ENV", "staging") + t.Setenv("APP_ENV", "STAGING") t.Setenv("UPLOAD_PATH", "/custom/uploads") t.Setenv("KEYS_PATH", "/custom/keys") t.Setenv("PORT", "8080") - t.Setenv("HOST", "127.0.0.1") + t.Setenv("HOST", "LOCALHOST") t.Setenv("UNIX_SOCKET", "/tmp/app.sock") t.Setenv("MAXMIND_LICENSE_KEY", "test-license") t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb") err := parseEnvConfig() require.NoError(t, err) - assert.Equal(t, "staging", EnvConfig.AppEnv) + assert.Equal(t, "staging", EnvConfig.AppEnv) // lowercased assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath) assert.Equal(t, "8080", EnvConfig.Port) - assert.Equal(t, "127.0.0.1", EnvConfig.Host) + assert.Equal(t, "localhost", EnvConfig.Host) // lowercased }) } -func TestResolveFileBasedEnvVariables(t *testing.T) { +func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) { // Create temporary directory for test files tempDir := t.TempDir() @@ -225,103 +225,34 @@ func TestResolveFileBasedEnvVariables(t *testing.T) { err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600) require.NoError(t, err) - // Create a binary file for testing binary data handling binaryKeyFile := tempDir + "/binary_key.bin" - binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10} + binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04} err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600) require.NoError(t, err) - t.Run("should read file content for fields with options:file tag", func(t *testing.T) { + t.Run("should process toLower and file options", func(t *testing.T) { config := defaultConfig() + config.AppEnv = "STAGING" + config.Host = "LOCALHOST" - // Set environment variables pointing to files t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile) t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile) - err := resolveFileBasedEnvVariables(&config) + err := prepareEnvConfig(&config) require.NoError(t, err) - // Verify file contents were read correctly + assert.Equal(t, "staging", config.AppEnv) + assert.Equal(t, "localhost", config.Host) assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey) assert.Equal(t, dbConnContent, config.DbConnectionString) }) - t.Run("should skip fields without options:file tag", func(t *testing.T) { - config := defaultConfig() - originalAppURL := config.AppURL - - // Set a file for a field that doesn't have options:file tag - t.Setenv("APP_URL_FILE", "/tmp/nonexistent.txt") - - err := resolveFileBasedEnvVariables(&config) - require.NoError(t, err) - - // AppURL should remain unchanged - assert.Equal(t, originalAppURL, config.AppURL) - }) - - t.Run("should skip non-string fields", func(t *testing.T) { - // This test verifies that non-string fields are skipped - // We test this indirectly by ensuring the function doesn't error - // when processing the actual EnvConfigSchema which has bool fields - config := defaultConfig() - - err := resolveFileBasedEnvVariables(&config) - require.NoError(t, err) - }) - - t.Run("should skip when _FILE environment variable is not set", func(t *testing.T) { - config := defaultConfig() - originalEncryptionKey := config.EncryptionKey - - // Don't set ENCRYPTION_KEY_FILE environment variable - - err := resolveFileBasedEnvVariables(&config) - require.NoError(t, err) - - // EncryptionKey should remain unchanged - assert.Equal(t, originalEncryptionKey, config.EncryptionKey) - }) - - t.Run("should handle multiple file-based variables simultaneously", func(t *testing.T) { - config := defaultConfig() - - // Set multiple file environment variables - t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile) - t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile) - - err := resolveFileBasedEnvVariables(&config) - require.NoError(t, err) - - // All should be resolved correctly - assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey) - assert.Equal(t, dbConnContent, config.DbConnectionString) - }) - - t.Run("should handle mixed file and non-file environment variables", func(t *testing.T) { - config := defaultConfig() - - // Set both file and non-file environment variables - t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile) - - err := resolveFileBasedEnvVariables(&config) - require.NoError(t, err) - - // File-based should be resolved, others should remain as set by env parser - assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey) - assert.Equal(t, "http://localhost:1411", config.AppURL) - }) - t.Run("should handle binary data correctly", func(t *testing.T) { config := defaultConfig() - - // Set environment variable pointing to binary file t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile) - err := resolveFileBasedEnvVariables(&config) + err := prepareEnvConfig(&config) require.NoError(t, err) - - // Verify binary data was read correctly without corruption assert.Equal(t, binaryKeyContent, config.EncryptionKey) }) }