Files
galaxy-game/gateway/internal/config/config_test.go
T
Ilia Denisov 1855e43699
Tests · Go / test (push) Successful in 1m42s
Tests · Go / test (pull_request) Successful in 1m45s
Tests · Integration / integration (pull_request) Successful in 1m36s
gateway: add CORS allow-list for the public REST surface
Adds a `GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS` env-driven allow-list
on the public REST server so the dev UI on https://www.galaxy.lan can
call https://api.galaxy.lan without the browser blocking the
cross-origin response. Defaults to empty (no CORS) so the production
posture stays closed.

The middleware mounts before route classification and anti-abuse, so
OPTIONS preflights never charge against per-class rate-limit buckets.

`tools/dev-deploy/docker-compose.yml` opts the dev gateway into a
single allowed origin (`https://www.galaxy.lan`); local-dev keeps the
defaults because Vite proxies through the same origin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:58:14 +02:00

252 lines
7.7 KiB
Go

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
}