package config import ( "crypto/ed25519" "crypto/sha256" "crypto/x509" "encoding/pem" "os" "path/filepath" "sync" "testing" "time" "galaxy/redisconn" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var configEnvMu sync.Mutex const ( gatewayRedisMasterAddrEnvVar = "GATEWAY_REDIS_MASTER_ADDR" gatewayRedisPasswordEnvVar = "GATEWAY_REDIS_PASSWORD" ) const ( defaultTestRedisMasterAddrValue = "127.0.0.1:6379" defaultTestRedisPasswordValue = "secret" defaultTestBackendHTTPURL = "http://127.0.0.1:8080" defaultTestBackendGRPCPushURL = "127.0.0.1:8081" defaultTestBackendClientID = "gw-test" ) func TestLoadFromEnvAppliesBackendDefaults(t *testing.T) { configEnvMu.Lock() defer configEnvMu.Unlock() resetEnv(t) setBaseRequiredEnv(t) cfg, err := LoadFromEnv() require.NoError(t, err) assert.Equal(t, defaultShutdownTimeout, cfg.ShutdownTimeout) assert.Equal(t, defaultLogLevel, cfg.Logging.Level) assert.Equal(t, defaultTestBackendHTTPURL, cfg.Backend.HTTPBaseURL) assert.Equal(t, defaultTestBackendGRPCPushURL, cfg.Backend.GRPCPushURL) assert.Equal(t, defaultTestBackendClientID, cfg.Backend.GatewayClientID) assert.Equal(t, defaultBackendHTTPTimeout, cfg.Backend.HTTPTimeout) assert.Equal(t, defaultBackendPushReconnectBaseBackoff, cfg.Backend.PushReconnectBaseBackoff) assert.Equal(t, defaultBackendPushReconnectMaxBackoff, cfg.Backend.PushReconnectMaxBackoff) expectedRedis := redisconn.DefaultConfig() expectedRedis.MasterAddr = defaultTestRedisMasterAddrValue expectedRedis.Password = defaultTestRedisPasswordValue assert.Equal(t, expectedRedis, cfg.Redis) assert.Equal(t, defaultReplayRedisKeyPrefix, cfg.ReplayRedis.KeyPrefix) assert.Equal(t, defaultReplayRedisReserveTimeout, cfg.ReplayRedis.ReserveTimeout) } func TestLoadFromEnvBackendOverrides(t *testing.T) { configEnvMu.Lock() defer configEnvMu.Unlock() resetEnv(t) setBaseRequiredEnv(t) t.Setenv(backendHTTPURLEnvVar, " http://backend.internal:9080/ ") t.Setenv(backendGRPCPushURLEnvVar, "backend.internal:9081") t.Setenv(backendGatewayClientIDEnvVar, "gw-prod-1") t.Setenv(backendHTTPTimeoutEnvVar, "7s") t.Setenv(backendPushReconnectBaseBackoffEnvVar, "750ms") t.Setenv(backendPushReconnectMaxBackoffEnvVar, "60s") cfg, err := LoadFromEnv() require.NoError(t, err) assert.Equal(t, "http://backend.internal:9080", cfg.Backend.HTTPBaseURL) assert.Equal(t, "backend.internal:9081", cfg.Backend.GRPCPushURL) assert.Equal(t, "gw-prod-1", cfg.Backend.GatewayClientID) assert.Equal(t, 7*time.Second, cfg.Backend.HTTPTimeout) assert.Equal(t, 750*time.Millisecond, cfg.Backend.PushReconnectBaseBackoff) assert.Equal(t, time.Minute, cfg.Backend.PushReconnectMaxBackoff) } func TestLoadFromEnvRejectsMissingBackendValues(t *testing.T) { cases := []struct { name string mutate func(t *testing.T) wantErr string }{ { name: "http url missing", mutate: func(t *testing.T) { os.Unsetenv(backendHTTPURLEnvVar) }, wantErr: backendHTTPURLEnvVar, }, { name: "grpc url missing", mutate: func(t *testing.T) { os.Unsetenv(backendGRPCPushURLEnvVar) }, wantErr: backendGRPCPushURLEnvVar, }, { name: "gateway client id missing", mutate: func(t *testing.T) { os.Unsetenv(backendGatewayClientIDEnvVar) }, wantErr: backendGatewayClientIDEnvVar, }, { name: "http url not absolute", mutate: func(t *testing.T) { t.Setenv(backendHTTPURLEnvVar, "/relative") }, wantErr: backendHTTPURLEnvVar, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { configEnvMu.Lock() defer configEnvMu.Unlock() resetEnv(t) setBaseRequiredEnv(t) tc.mutate(t) _, err := LoadFromEnv() require.Error(t, err) assert.Contains(t, err.Error(), tc.wantErr) }) } } func TestLoadFromEnvRejectsInvalidPushBackoff(t *testing.T) { configEnvMu.Lock() defer configEnvMu.Unlock() resetEnv(t) setBaseRequiredEnv(t) t.Setenv(backendPushReconnectBaseBackoffEnvVar, "1s") t.Setenv(backendPushReconnectMaxBackoffEnvVar, "500ms") _, err := LoadFromEnv() require.Error(t, err) assert.Contains(t, err.Error(), backendPushReconnectMaxBackoffEnvVar) } func TestLoadFromEnvAppliesPublicAndAuthGRPCDefaults(t *testing.T) { configEnvMu.Lock() defer configEnvMu.Unlock() resetEnv(t) setBaseRequiredEnv(t) cfg, err := LoadFromEnv() require.NoError(t, err) assert.Equal(t, defaultPublicHTTPAddr, cfg.PublicHTTP.Addr) assert.Equal(t, defaultPublicHTTPReadHeaderTimeout, cfg.PublicHTTP.ReadHeaderTimeout) assert.Equal(t, defaultPublicHTTPReadTimeout, cfg.PublicHTTP.ReadTimeout) assert.Equal(t, defaultPublicHTTPIdleTimeout, cfg.PublicHTTP.IdleTimeout) assert.Equal(t, defaultPublicAuthUpstreamTimeout, cfg.PublicHTTP.AuthUpstreamTimeout) assert.Empty(t, cfg.PublicHTTP.CORSAllowedOrigins, "default disables CORS") assert.Equal(t, defaultAuthenticatedGRPCAddr, cfg.AuthenticatedGRPC.Addr) assert.Equal(t, defaultAuthenticatedGRPCConnectionTimeout, cfg.AuthenticatedGRPC.ConnectionTimeout) assert.Equal(t, defaultAuthenticatedGRPCDownstreamTimeout, cfg.AuthenticatedGRPC.DownstreamTimeout) assert.Equal(t, defaultAuthenticatedGRPCFreshnessWindow, cfg.AuthenticatedGRPC.FreshnessWindow) } func TestLoadFromEnvParsesCORSAllowedOrigins(t *testing.T) { configEnvMu.Lock() defer configEnvMu.Unlock() resetEnv(t) setBaseRequiredEnv(t) t.Setenv(publicHTTPCORSAllowedOriginsEnvVar, "https://www.galaxy.lan, , https://staging.galaxy.lan") cfg, err := LoadFromEnv() require.NoError(t, err) assert.Equal(t, []string{"https://www.galaxy.lan", "https://staging.galaxy.lan"}, cfg.PublicHTTP.CORSAllowedOrigins, "comma-separated list is split, whitespace-trimmed, and empty segments dropped") } // resetEnv clears every env var the gateway config might read so that // individual tests can build the exact environment they need without // leakage from a previous test. func resetEnv(t *testing.T) { t.Helper() for _, name := range []string{ shutdownTimeoutEnvVar, logLevelEnvVar, publicHTTPAddrEnvVar, publicHTTPReadHeaderTimeoutEnvVar, publicHTTPReadTimeoutEnvVar, publicHTTPIdleTimeoutEnvVar, publicAuthUpstreamTimeoutEnvVar, publicHTTPCORSAllowedOriginsEnvVar, backendHTTPURLEnvVar, backendGRPCPushURLEnvVar, backendGatewayClientIDEnvVar, backendHTTPTimeoutEnvVar, backendPushReconnectBaseBackoffEnvVar, backendPushReconnectMaxBackoffEnvVar, adminHTTPAddrEnvVar, adminHTTPReadHeaderTimeoutEnvVar, adminHTTPReadTimeoutEnvVar, adminHTTPIdleTimeoutEnvVar, authenticatedGRPCAddrEnvVar, authenticatedGRPCConnectionTimeoutEnvVar, authenticatedGRPCDownstreamTimeoutEnvVar, authenticatedGRPCFreshnessWindowEnvVar, gatewayRedisMasterAddrEnvVar, gatewayRedisPasswordEnvVar, replayRedisKeyPrefixEnvVar, replayRedisReserveTimeoutEnvVar, responseSignerPrivateKeyPEMPathEnvVar, } { os.Unsetenv(name) } } func setBaseRequiredEnv(t *testing.T) { t.Helper() t.Setenv(gatewayRedisMasterAddrEnvVar, defaultTestRedisMasterAddrValue) t.Setenv(gatewayRedisPasswordEnvVar, defaultTestRedisPasswordValue) t.Setenv(backendHTTPURLEnvVar, defaultTestBackendHTTPURL) t.Setenv(backendGRPCPushURLEnvVar, defaultTestBackendGRPCPushURL) t.Setenv(backendGatewayClientIDEnvVar, defaultTestBackendClientID) t.Setenv(responseSignerPrivateKeyPEMPathEnvVar, writeTestResponseSignerPEMFile(t)) } func writeTestResponseSignerPEMFile(t *testing.T) string { t.Helper() seed := sha256.Sum256([]byte("gateway-config-test-response-signer")) privateKey := ed25519.NewKeyFromSeed(seed[:]) encodedPrivateKey, err := x509.MarshalPKCS8PrivateKey(privateKey) require.NoError(t, err) path := filepath.Join(t.TempDir(), "response-signer.pem") require.NoError(t, os.WriteFile(path, pem.EncodeToMemory(&pem.Block{ Type: "PRIVATE KEY", Bytes: encodedPrivateKey, }), 0o600)) return path }