package config import ( "os" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" ) func TestConfig_Validate(t *testing.T) { tests := []struct { name string config *Config wantErr bool errMsg string }{ { name: "valid config", config: &Config{ AppPort: 8080, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: false, }, { name: "invalid port too low", config: &Config{ AppPort: 0, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: true, errMsg: "APP_PORT validation failed", }, { name: "invalid port too high", config: &Config{ AppPort: 99999, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: true, errMsg: "APP_PORT validation failed", }, { name: "JWT secret too short", config: &Config{ AppPort: 8080, JWTSecret: "short", DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: true, errMsg: "JWT_SECRET validation failed", }, { name: "JWT secret empty", config: &Config{ AppPort: 8080, JWTSecret: "", DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: true, errMsg: "JWT_SECRET validation failed", }, { name: "JWT secret exactly 32 characters", config: &Config{ AppPort: 8080, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: false, }, { name: "DatabaseURL empty", config: &Config{ AppPort: 8080, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: true, errMsg: "DATABASE_URL is required", }, { name: "RedisURL empty", config: &Config{ AppPort: 8080, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: true, errMsg: "REDIS_URL is required", }, { name: "DatabaseURL invalid format", config: &Config{ AppPort: 8080, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "invalid://database", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: true, errMsg: "DATABASE_URL validation failed", }, { name: "RedisURL invalid format", config: &Config{ AppPort: 8080, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "invalid://redis", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: true, errMsg: "REDIS_URL validation failed", }, { name: "DatabaseURL postgres format", config: &Config{ AppPort: 8080, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgres://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: false, }, { name: "DatabaseURL sqlite format", config: &Config{ AppPort: 8080, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "sqlite:///path/to/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: false, }, { name: "RedisURL rediss format (TLS)", config: &Config{ AppPort: 8080, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "rediss://localhost:6379", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: false, }, { name: "valid port boundaries", config: &Config{ AppPort: 1, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: false, }, { name: "valid port upper boundary", config: &Config{ AppPort: 65535, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: false, }, { name: "invalid LogLevel", config: &Config{ AppPort: 8080, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", LogLevel: "INVALID", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: true, errMsg: "LOG_LEVEL validation failed", }, { name: "valid LogLevel", config: &Config{ AppPort: 8080, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", LogLevel: "DEBUG", RateLimitLimit: 100, // Added RateLimitWindow: 60, // Added }, wantErr: false, }, { name: "invalid RateLimitLimit zero", config: &Config{ AppPort: 8080, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 0, RateLimitWindow: 60, // Added }, wantErr: true, errMsg: "RATE_LIMIT_LIMIT validation failed", }, { name: "invalid RateLimitWindow negative", config: &Config{ AppPort: 8080, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, // Added RateLimitWindow: -1, }, wantErr: true, errMsg: "RATE_LIMIT_WINDOW validation failed", }, { name: "valid RateLimit values", config: &Config{ AppPort: 8080, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, RateLimitWindow: 60, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Ajouter un logger minimal si nécessaire pour éviter nil pointer if tt.config.Logger == nil { logger, _ := zap.NewDevelopment() tt.config.Logger = logger } err := tt.config.Validate() if tt.wantErr { require.Error(t, err) if tt.errMsg != "" { assert.Contains(t, err.Error(), tt.errMsg) } } else { require.NoError(t, err) } }) } } // TestValidateNoBypassFlagsInProduction vérifie que les bypass flags bloquent le démarrage en production (audit 1.7) func TestValidateNoBypassFlagsInProduction(t *testing.T) { validConfig := &Config{ Env: "development", AppPort: 8080, JWTSecret: strings.Repeat("a", 32), DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, RateLimitWindow: 60, } t.Run("production with BYPASS_CONTENT_CREATOR_ROLE fails", func(t *testing.T) { orig := os.Getenv("BYPASS_CONTENT_CREATOR_ROLE") defer func() { if orig != "" { os.Setenv("BYPASS_CONTENT_CREATOR_ROLE", orig) } else { os.Unsetenv("BYPASS_CONTENT_CREATOR_ROLE") } }() os.Setenv("BYPASS_CONTENT_CREATOR_ROLE", "true") cfg := *validConfig cfg.Env = "production" err := cfg.Validate() require.Error(t, err) assert.Contains(t, err.Error(), "BYPASS_CONTENT_CREATOR_ROLE") assert.Contains(t, err.Error(), "bypass flags") }) t.Run("production with CSRF_DISABLED fails", func(t *testing.T) { orig := os.Getenv("CSRF_DISABLED") defer func() { if orig != "" { os.Setenv("CSRF_DISABLED", orig) } else { os.Unsetenv("CSRF_DISABLED") } }() os.Setenv("CSRF_DISABLED", "true") cfg := *validConfig cfg.Env = "production" err := cfg.Validate() require.Error(t, err) assert.Contains(t, err.Error(), "CSRF_DISABLED") assert.Contains(t, err.Error(), "bypass flags") }) t.Run("production with DISABLE_RATE_LIMIT_FOR_TESTS fails", func(t *testing.T) { orig := os.Getenv("DISABLE_RATE_LIMIT_FOR_TESTS") defer func() { if orig != "" { os.Setenv("DISABLE_RATE_LIMIT_FOR_TESTS", orig) } else { os.Unsetenv("DISABLE_RATE_LIMIT_FOR_TESTS") } }() os.Setenv("DISABLE_RATE_LIMIT_FOR_TESTS", "true") cfg := *validConfig cfg.Env = "production" err := cfg.Validate() require.Error(t, err) assert.Contains(t, err.Error(), "DISABLE_RATE_LIMIT_FOR_TESTS") assert.Contains(t, err.Error(), "bypass flags") }) t.Run("development with bypass flags succeeds", func(t *testing.T) { origBypass := os.Getenv("BYPASS_CONTENT_CREATOR_ROLE") origCsrf := os.Getenv("CSRF_DISABLED") defer func() { if origBypass != "" { os.Setenv("BYPASS_CONTENT_CREATOR_ROLE", origBypass) } else { os.Unsetenv("BYPASS_CONTENT_CREATOR_ROLE") } if origCsrf != "" { os.Setenv("CSRF_DISABLED", origCsrf) } else { os.Unsetenv("CSRF_DISABLED") } }() os.Setenv("BYPASS_CONTENT_CREATOR_ROLE", "true") os.Setenv("CSRF_DISABLED", "true") cfg := *validConfig cfg.Env = "development" err := cfg.Validate() require.NoError(t, err) }) } // TestValidateForEnvironment_ClamAVRequiredInProduction verifies that CLAMAV_REQUIRED=false fails in production (P1.4) func TestValidateForEnvironment_ClamAVRequiredInProduction(t *testing.T) { origClamAV := os.Getenv("CLAMAV_REQUIRED") origRedis := os.Getenv("REDIS_URL") defer func() { if origClamAV != "" { os.Setenv("CLAMAV_REQUIRED", origClamAV) } else { os.Unsetenv("CLAMAV_REQUIRED") } if origRedis != "" { os.Setenv("REDIS_URL", origRedis) } else { os.Unsetenv("REDIS_URL") } }() os.Setenv("REDIS_URL", "redis://:password@prod-redis:6379") cfg := &Config{ Env: EnvProduction, AppPort: 8080, JWTSecret: strings.Repeat("a", 32), ChatJWTSecret: strings.Repeat("b", 32), // ≠ JWTSecret (required in prod) OAuthEncryptionKey: strings.Repeat("c", 32), JWTIssuer: "veza-api", JWTAudience: "veza-platform", DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, RateLimitWindow: 60, CORSOrigins: []string{"https://example.com"}, LogLevel: "INFO", HyperswitchEnabled: true, // v1.0.4: payments must be on in prod } logger, _ := zap.NewDevelopment() cfg.Logger = logger t.Run("production with CLAMAV_REQUIRED=false fails", func(t *testing.T) { os.Setenv("CLAMAV_REQUIRED", "false") err := cfg.ValidateForEnvironment() require.Error(t, err) assert.Contains(t, err.Error(), "CLAMAV_REQUIRED must be true in production") }) t.Run("production with CLAMAV_REQUIRED=true succeeds", func(t *testing.T) { os.Setenv("CLAMAV_REQUIRED", "true") err := cfg.ValidateForEnvironment() require.NoError(t, err) }) } // TestValidateForEnvironment_HyperswitchRequiredInProduction verifies that // HYPERSWITCH_ENABLED=false fails in production (v1.0.4 marketplace-gratuit fix). func TestValidateForEnvironment_HyperswitchRequiredInProduction(t *testing.T) { origClamAV := os.Getenv("CLAMAV_REQUIRED") origRedis := os.Getenv("REDIS_URL") defer func() { if origClamAV != "" { os.Setenv("CLAMAV_REQUIRED", origClamAV) } else { os.Unsetenv("CLAMAV_REQUIRED") } if origRedis != "" { os.Setenv("REDIS_URL", origRedis) } else { os.Unsetenv("REDIS_URL") } }() os.Setenv("CLAMAV_REQUIRED", "true") os.Setenv("REDIS_URL", "redis://:password@prod-redis:6379") baseCfg := func() *Config { c := &Config{ Env: EnvProduction, AppPort: 8080, JWTSecret: strings.Repeat("a", 32), ChatJWTSecret: strings.Repeat("b", 32), OAuthEncryptionKey: strings.Repeat("c", 32), JWTIssuer: "veza-api", JWTAudience: "veza-platform", DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, RateLimitWindow: 60, CORSOrigins: []string{"https://example.com"}, LogLevel: "INFO", } logger, _ := zap.NewDevelopment() c.Logger = logger return c } t.Run("production with HYPERSWITCH_ENABLED=false fails", func(t *testing.T) { cfg := baseCfg() cfg.HyperswitchEnabled = false err := cfg.ValidateForEnvironment() require.Error(t, err) assert.Contains(t, err.Error(), "HYPERSWITCH_ENABLED must be true in production") }) t.Run("production with HYPERSWITCH_ENABLED=true succeeds", func(t *testing.T) { cfg := baseCfg() cfg.HyperswitchEnabled = true err := cfg.ValidateForEnvironment() require.NoError(t, err) }) t.Run("non-production is unaffected", func(t *testing.T) { cfg := baseCfg() cfg.Env = EnvDevelopment cfg.HyperswitchEnabled = false // Dev doesn't require HyperswitchEnabled — marketplace disabled is fine locally. err := cfg.ValidateForEnvironment() require.NoError(t, err) }) } // TestValidateForEnvironment_RedisURLRequiredInProduction verifies that an // unset REDIS_URL fails in production (v1.0.4 multi-pod fallback fix). func TestValidateForEnvironment_RedisURLRequiredInProduction(t *testing.T) { origClamAV := os.Getenv("CLAMAV_REQUIRED") origRedis := os.Getenv("REDIS_URL") defer func() { if origClamAV != "" { os.Setenv("CLAMAV_REQUIRED", origClamAV) } else { os.Unsetenv("CLAMAV_REQUIRED") } if origRedis != "" { os.Setenv("REDIS_URL", origRedis) } else { os.Unsetenv("REDIS_URL") } }() os.Setenv("CLAMAV_REQUIRED", "true") cfg := &Config{ Env: EnvProduction, AppPort: 8080, JWTSecret: strings.Repeat("a", 32), ChatJWTSecret: strings.Repeat("b", 32), OAuthEncryptionKey: strings.Repeat("c", 32), JWTIssuer: "veza-api", JWTAudience: "veza-platform", DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", // struct field is valid (default) RateLimitLimit: 100, RateLimitWindow: 60, CORSOrigins: []string{"https://example.com"}, LogLevel: "INFO", HyperswitchEnabled: true, } logger, _ := zap.NewDevelopment() cfg.Logger = logger t.Run("production with unset REDIS_URL env fails", func(t *testing.T) { os.Unsetenv("REDIS_URL") err := cfg.ValidateForEnvironment() require.Error(t, err) assert.Contains(t, err.Error(), "REDIS_URL must be explicitly set in production") }) t.Run("production with REDIS_URL env succeeds", func(t *testing.T) { os.Setenv("REDIS_URL", "redis://:password@prod-redis:6379") err := cfg.ValidateForEnvironment() require.NoError(t, err) }) } // TestValidateForEnvironment_ChatJWTSecretInProduction verifies CHAT_JWT_SECRET must differ from JWT_SECRET in production (v0.902) func TestValidateForEnvironment_ChatJWTSecretInProduction(t *testing.T) { secret := strings.Repeat("a", 32) cfg := &Config{ Env: EnvProduction, AppPort: 8080, JWTSecret: secret, ChatJWTSecret: secret, // Same as JWT_SECRET - should fail DatabaseURL: "postgresql://user:pass@localhost:5432/db", RedisURL: "redis://localhost:6379", RateLimitLimit: 100, RateLimitWindow: 60, CORSOrigins: []string{"https://example.com"}, LogLevel: "INFO", OAuthEncryptionKey: strings.Repeat("b", 32), } logger, _ := zap.NewDevelopment() cfg.Logger = logger os.Setenv("CLAMAV_REQUIRED", "true") err := cfg.ValidateForEnvironment() require.Error(t, err) assert.Contains(t, err.Error(), "CHAT_JWT_SECRET must be different from JWT_SECRET") }