veza/veza-backend-api/internal/config/validation_test.go
senke 03b30c0c29 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>
2026-04-16 14:55:18 +02:00

530 lines
15 KiB
Go

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_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")
}