fix(config): refuse boot in production when HYPERSWITCH_ENABLED=false
With payments disabled, the marketplace flow still completes: orders are
created with status `CREATED`, the download URL is released, and no PSP
call is ever made. In other words: on a misconfigured prod instance, every
purchase is free. The only signal was a silent `hyperswitch_enabled=false`
at boot.
`ValidateForEnvironment()` (already wired at `NewConfig` line 513, before
the HTTP listener binds) now rejects `APP_ENV=production` with
`HyperswitchEnabled=false`. The error message names the failure mode
explicitly ("effectively giving away products") rather than a terse
"config invalid" — this is a revenue leak, not a typo.
Dev and staging are unaffected.
Tests: 3 new cases in `validation_test.go`
(`TestValidateForEnvironment_HyperswitchRequiredInProduction`) +
`TestLoadConfig_ProdValid` updated to set `HyperswitchEnabled: true`.
`TestValidateForEnvironment_ClamAVRequiredInProduction` fixture also
includes the new field so its "succeeds" sub-test still runs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9ed60e5719
commit
03b30c0c29
3 changed files with 93 additions and 4 deletions
|
|
@ -861,6 +861,13 @@ func (c *Config) ValidateForEnvironment() error {
|
|||
return fmt.Errorf("JWT_ISSUER and JWT_AUDIENCE must be set in production for consistent JWT validation. Set JWT_ISSUER and JWT_AUDIENCE environment variables")
|
||||
}
|
||||
|
||||
// 9. Hyperswitch must be enabled in production — otherwise the marketplace
|
||||
// silently "sells" products without taking payment (orders complete as
|
||||
// CREATED and files are released for free).
|
||||
if !c.HyperswitchEnabled {
|
||||
return fmt.Errorf("HYPERSWITCH_ENABLED must be true in production. With payments disabled, marketplace orders complete without charging, effectively giving away products. Set HYPERSWITCH_ENABLED=true and configure HYPERSWITCH_API_KEY / HYPERSWITCH_WEBHOOK_SECRET")
|
||||
}
|
||||
|
||||
case EnvTest:
|
||||
// TEST: Validation adaptée aux tests
|
||||
// CORS peut être vide ou configuré explicitement
|
||||
|
|
|
|||
|
|
@ -638,6 +638,7 @@ func TestLoadConfig_ProdValid(t *testing.T) {
|
|||
originalEnv := os.Getenv("APP_ENV")
|
||||
originalCORSOrigins := os.Getenv("CORS_ALLOWED_ORIGINS")
|
||||
originalClamAV := os.Getenv("CLAMAV_REQUIRED")
|
||||
originalRedisURL := os.Getenv("REDIS_URL")
|
||||
|
||||
// Nettoyer après le test
|
||||
defer func() {
|
||||
|
|
@ -656,11 +657,17 @@ func TestLoadConfig_ProdValid(t *testing.T) {
|
|||
} else {
|
||||
os.Unsetenv("CLAMAV_REQUIRED")
|
||||
}
|
||||
if originalRedisURL != "" {
|
||||
os.Setenv("REDIS_URL", originalRedisURL)
|
||||
} else {
|
||||
os.Unsetenv("REDIS_URL")
|
||||
}
|
||||
}()
|
||||
|
||||
// Configuration pour production valide
|
||||
os.Setenv("APP_ENV", "production")
|
||||
os.Setenv("CLAMAV_REQUIRED", "true")
|
||||
os.Setenv("REDIS_URL", "redis://:password@prod-redis:6379")
|
||||
|
||||
// Créer une config minimale valide (tous les champs requis en prod)
|
||||
cfg := &Config{
|
||||
|
|
@ -671,12 +678,13 @@ func TestLoadConfig_ProdValid(t *testing.T) {
|
|||
JWTIssuer: "veza-api",
|
||||
JWTAudience: "veza-platform",
|
||||
DatabaseURL: "postgresql://test:test@localhost:5432/test_db",
|
||||
RedisURL: "redis://localhost:6379",
|
||||
RedisURL: "redis://:password@prod-redis:6379",
|
||||
AppPort: 8080,
|
||||
LogLevel: "INFO",
|
||||
RateLimitLimit: 100, // Valeur valide pour passer Validate()
|
||||
RateLimitWindow: 60, // Valeur valide pour passer Validate()
|
||||
CORSOrigins: []string{"https://app.veza.com", "https://www.veza.com"}, // Valide - pas de wildcard
|
||||
HyperswitchEnabled: true, // Payments must be on in prod (v1.0.4)
|
||||
}
|
||||
|
||||
// Créer un logger minimal pour la config
|
||||
|
|
|
|||
|
|
@ -389,14 +389,21 @@ func TestValidateNoBypassFlagsInProduction(t *testing.T) {
|
|||
|
||||
// TestValidateForEnvironment_ClamAVRequiredInProduction verifies that CLAMAV_REQUIRED=false fails in production (P1.4)
|
||||
func TestValidateForEnvironment_ClamAVRequiredInProduction(t *testing.T) {
|
||||
orig := os.Getenv("CLAMAV_REQUIRED")
|
||||
origClamAV := os.Getenv("CLAMAV_REQUIRED")
|
||||
origRedis := os.Getenv("REDIS_URL")
|
||||
defer func() {
|
||||
if orig != "" {
|
||||
os.Setenv("CLAMAV_REQUIRED", orig)
|
||||
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,
|
||||
|
|
@ -412,6 +419,7 @@ func TestValidateForEnvironment_ClamAVRequiredInProduction(t *testing.T) {
|
|||
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
|
||||
|
|
@ -430,6 +438,72 @@ func TestValidateForEnvironment_ClamAVRequiredInProduction(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// 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_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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue