proxy-pool/internal/config/config_test.go
2026-08-02 16:47:51 +08:00

1100 lines
33 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"go.yaml.in/yaml/v4"
"proxy-pool/internal/domain/authorization"
"proxy-pool/internal/domain/clientpolicy"
)
const validConfig = `
version: 1
security:
requireProtectionOnPublicListen: true
gateway:
enabled: true
listen: 127.0.0.1:8080
auth:
mode: none
distribution:
enabled: true
listen: 127.0.0.1:8081
auth:
mode: none
extraction:
fulfillment: partial
maxCountPerRequest: 20
minRemainingTTL: 30s
maxHealthCheckAge: 15s
reserveForGateway: 5
routing:
- name: extract
enabled: true
purpose: extract
upstreams: [provider-a, provider-b]
strategy:
type: sequential
switchAfterEmptyFetch: 5
endBehavior: stayLast
onUnavailable:
action: reject
upstreams:
provider-a: &valid-upstream
enabled: true
exposure: [gateway, extract]
provider:
billingMode: fetch
protocols: [http]
api:
url: https://provider.example/proxies
method: GET
template: '{{.}}'
auth:
type: none
proxyAuth:
type: response
pool:
maxSize: 100
capacity:
maxConcurrencyPerProxy: 10
refill:
reconcileInterval: 1s
minimumAvailableSlots: 200
targetAvailableSlots: 500
lifecycle:
ttl: 120s
allocationSafetyMargin: 10s
fetch:
estimatedIPsPerCall: 20
requestInterval: 1s
timeout: 3s
maxAttempts: 5
maxInFlight: 1
maxTotal: 1000
check:
interval: 30s
jitter: 20
maxInFlight: 100
timeout: 2s
maxAttempts: 2
maxConsecutiveFailures: 3
urls: [http://connect.rom.miui.com/generate_204]
provider-b: *valid-upstream
`
func TestLoadStrictValidConfiguration(t *testing.T) {
cfg, err := Load(strings.NewReader(validConfig))
if err != nil {
t.Fatalf("Load(): %v", err)
}
provider := cfg.Upstreams["provider-a"]
if cfg.Version != 1 || provider.Pool.MaxSize != 100 || provider.Fetch.EstimatedIPsPerCall != 20 ||
provider.Refill.ReconcileInterval.Value() != time.Second || provider.Refill.MinimumAvailableSlots != 200 ||
provider.Refill.TargetAvailableSlots != 500 {
t.Fatalf("unexpected config: %+v", cfg)
}
}
func TestLoadPreservesOmittedAndExplicitDestinationPolicyBooleans(t *testing.T) {
source := strings.Replace(validConfig,
" auth:\n mode: none\n",
" auth:\n mode: none\n destinationPolicy:\n denyLoopback: false\n",
1,
)
cfg, err := Load(strings.NewReader(source))
if err != nil {
t.Fatalf("Load() error = %v", err)
}
policy := cfg.Gateway.DestinationPolicy
if policy.DenyLoopback == nil || *policy.DenyLoopback {
t.Fatalf("denyLoopback = %v, want explicit false", policy.DenyLoopback)
}
if policy.DenyPrivateNetworks != nil || policy.DenyLinkLocal != nil {
t.Fatalf("omitted policy fields = (%v, %v), want nil", policy.DenyPrivateNetworks, policy.DenyLinkLocal)
}
redacted := cfg.Redacted()
*redacted.Gateway.DestinationPolicy.DenyLoopback = true
if *cfg.Gateway.DestinationPolicy.DenyLoopback {
t.Fatal("Redacted() destination policy aliases source configuration")
}
}
func TestLoadRejectsUnknownFields(t *testing.T) {
_, err := Load(strings.NewReader(validConfig + "unknownField: true\n"))
if err == nil || !strings.Contains(err.Error(), "unknownField") {
t.Fatalf("Load() error = %v, want unknown field error", err)
}
}
func TestValidateRejectsUnprotectedPublicListener(t *testing.T) {
cfg, err := Load(strings.NewReader(strings.Replace(validConfig,
"listen: 127.0.0.1:8080", "listen: 0.0.0.0:8080", 1)))
if err == nil || !strings.Contains(err.Error(), "gateway") || !strings.Contains(err.Error(), "public") {
t.Fatalf("Load() error = %v, want unprotected public listener error", err)
}
if cfg != nil {
t.Fatal("invalid config must not be returned")
}
}
func TestValidateRejectsMissingUpstreamReference(t *testing.T) {
broken := strings.Replace(validConfig, "upstreams: [provider-a, provider-b]", "upstreams: [provider-a, missing]", 1)
_, err := Load(strings.NewReader(broken))
if err == nil || !strings.Contains(err.Error(), "missing") {
t.Fatalf("Load() error = %v, want missing upstream error", err)
}
}
func TestValidateSeparatesPoolAndFetchLimits(t *testing.T) {
broken := strings.Replace(validConfig, "maxTotal: 1000", "maxTotal: 50", 1)
_, err := Load(strings.NewReader(broken))
if err == nil || !strings.Contains(err.Error(), "maxTotal") {
t.Fatalf("Load() error = %v, want maxTotal validation error", err)
}
}
func TestShippedConfigurationsAreValid(t *testing.T) {
paths, err := filepath.Glob(filepath.Join("..", "..", "examples", "config", "*.yaml"))
if err != nil {
t.Fatalf("Glob(): %v", err)
}
paths = append(paths, filepath.Join("..", "..", "configs", "proxy-pool.yaml"))
if len(paths) != 21 {
t.Fatalf("configuration count = %d, want 21", len(paths))
}
for _, path := range paths {
path := path
t.Run(filepath.Base(path), func(t *testing.T) {
file, err := os.Open(path)
if err != nil {
t.Fatalf("Open(): %v", err)
}
defer file.Close()
if _, err := Load(file); err != nil {
t.Fatalf("Load(): %v", err)
}
})
}
}
func TestShippedDeploymentConfigurationsResolveEnvironment(t *testing.T) {
resolver := fixtureResolver{environment: map[string]string{
"PROXY_POOL_GATEWAY_USERNAME": "resolved-gateway-user",
"PROXY_POOL_GATEWAY_PASSWORD": "resolved-gateway-password",
"PROXY_POOL_EXTRACT_TOKEN": "resolved-extract-token",
"PROXY_POOL_ADMIN_TOKEN": "resolved-admin-token",
"PROXY_POOL_POSTGRES_URL": "postgres://resolved",
"PROXY_POOL_REDIS_URL": "redis://resolved",
"PROVIDER_A_TOKEN": "resolved-provider-a-token",
"PROVIDER_B_TOKEN": "resolved-provider-b-token",
}}
tests := []struct {
name string
path string
configMap bool
wantGatewayUser string
wantPostgresURL string
wantRedisURL string
}{
{
name: "local",
path: filepath.Join("..", "..", "deploy", "config", "local.yaml"),
wantGatewayUser: "local-gateway",
wantPostgresURL: "postgres://proxy_pool:local-only-change-me@postgres:5432/proxy_pool?sslmode=disable",
wantRedisURL: "redis://redis:6379/0",
},
{
name: "kubernetes",
path: filepath.Join("..", "..", "deploy", "kubernetes", "base", "configmap.yaml"),
configMap: true,
wantGatewayUser: "resolved-gateway-user",
wantPostgresURL: "postgres://resolved",
wantRedisURL: "redis://resolved",
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
content, err := os.ReadFile(test.path)
if err != nil {
t.Fatalf("ReadFile(): %v", err)
}
configuration := string(content)
if test.configMap {
var manifest struct {
Data map[string]string `yaml:"data"`
}
if err := yaml.Unmarshal(content, &manifest); err != nil {
t.Fatalf("Unmarshal(): %v", err)
}
configuration = manifest.Data["config.yaml"]
}
cfg, err := LoadResolved(strings.NewReader(configuration), resolver)
if err != nil {
t.Fatalf("LoadResolved(): %v", err)
}
if cfg.Gateway.Auth.Username != test.wantGatewayUser ||
cfg.Gateway.Auth.Password != "resolved-gateway-password" ||
cfg.Distribution.Auth.Token != "resolved-extract-token" ||
cfg.Admin.Auth.Token != "resolved-admin-token" ||
cfg.Storage.PostgresURL != test.wantPostgresURL ||
cfg.Storage.RedisURL != test.wantRedisURL ||
cfg.Upstreams["provider-a"].API.Auth.Value != "resolved-provider-a-token" ||
cfg.Upstreams["provider-b"].API.Auth.Value != "resolved-provider-b-token" {
t.Fatalf("deployment values were not resolved: %+v", cfg.Redacted())
}
})
}
}
func TestLoadResolvedExpandsEnvironmentWithoutChangingTemplateVariables(t *testing.T) {
configured := strings.Replace(validConfig, ` auth:
type: none`, ` auth:
type: basic
username: "${PROVIDER_USER}"
password: "${PROVIDER_PASSWORD}"`, 1)
configured = strings.Replace(configured, "template: '{{.}}'", "template: '{{$x := .}}{{$x}}'", 1)
resolver := fixtureResolver{environment: map[string]string{
"PROVIDER_USER": "alice",
"PROVIDER_PASSWORD": "secret",
}}
cfg, err := LoadResolved(strings.NewReader(configured), resolver)
if err != nil {
t.Fatalf("LoadResolved(): %v", err)
}
auth := cfg.Upstreams["provider-a"].API.Auth
if auth.Username != "alice" || auth.Password != "secret" {
t.Fatalf("resolved auth = %+v", auth)
}
if got := cfg.Upstreams["provider-a"].API.Template; got != "{{$x := .}}{{$x}}" {
t.Fatalf("template = %q, want template variable unchanged", got)
}
}
func TestLoadResolvedReadsSecretFileAndClearsReference(t *testing.T) {
configured := strings.Replace(validConfig, ` auth:
type: none`, ` auth:
type: basic
username: alice
passwordFile: /run/secrets/provider-password`, 1)
resolver := fixtureResolver{files: map[string]string{
"/run/secrets/provider-password": "file-secret\r\n",
}}
cfg, err := LoadResolved(strings.NewReader(configured), resolver)
if err != nil {
t.Fatalf("LoadResolved(): %v", err)
}
auth := cfg.Upstreams["provider-a"].API.Auth
if auth.Password != "file-secret" || auth.PasswordFile != "" {
t.Fatalf("resolved auth = %+v", auth)
}
}
func TestLoadRejectsMultipleYAMLDocuments(t *testing.T) {
_, err := Load(strings.NewReader(validConfig + "\n---\nversion: 1\n"))
if err == nil || !strings.Contains(err.Error(), "multiple YAML documents") {
t.Fatalf("Load() error = %v, want multiple document error", err)
}
}
func TestValidateRejectsUnsupportedListenerAuthMode(t *testing.T) {
broken := strings.Replace(validConfig, "mode: none", "mode: custom", 1)
_, err := Load(strings.NewReader(broken))
if err == nil || !strings.Contains(err.Error(), "auth.mode") {
t.Fatalf("Load() error = %v, want auth.mode error", err)
}
}
func TestValidateAcceptsBearerListenerAuthentication(t *testing.T) {
cfg := mustLoadValidConfig(t)
cfg.Distribution.Auth = Auth{Mode: "bearer", Token: "resolved-token"}
cfg.Distribution.ClientIdentification.Mode = "authenticatedClient"
if err := Validate(cfg); err != nil {
t.Fatalf("Validate(bearer) error = %v", err)
}
cfg.Distribution.Auth = Auth{Mode: "any", Methods: []AuthMethod{{Mode: "bearer", Value: "method-token"}}}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate(any bearer) error = %v", err)
}
}
func TestValidateListenerPermissionConfiguration(t *testing.T) {
t.Parallel()
cfg := mustLoadValidConfig(t)
cfg.Admin.Enabled = true
cfg.Admin.Listen = "127.0.0.1:8082"
cfg.Admin.Auth = Auth{
Mode: "bearer",
Token: "admin-token",
Permissions: []string{authorization.AdminRead},
}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate(admin read permission) error = %v", err)
}
cfg.Admin.Auth.Permissions = []string{"unknown"}
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "permissions") {
t.Fatalf("Validate(unknown permission) error = %v", err)
}
cfg.Admin.Auth = Auth{
Mode: "any",
Permissions: []string{authorization.AdminRead},
Methods: []AuthMethod{{
Mode: "bearer", Value: "read-token", Permissions: []string{authorization.AdminRead},
}},
}
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "auth.permissions") {
t.Fatalf("Validate(any top-level permission) error = %v", err)
}
cfg.Admin.Auth.Permissions = nil
if err := Validate(cfg); err != nil {
t.Fatalf("Validate(any method permission) error = %v", err)
}
}
func TestValidateDistributionCredentialClientPolicy(t *testing.T) {
t.Parallel()
cfg, err := Load(strings.NewReader(validConfig))
if err != nil {
t.Fatalf("Load() error = %v", err)
}
cfg.Distribution.Auth = Auth{
Mode: "bearer", Token: "distribution-token",
ClientPolicy: clientpolicy.Policy{
MaxExtractCount: 5,
AllowedUpstreams: []string{"provider-a"},
AllowedRegions: []string{"shanghai"},
},
}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate(distribution client policy) error = %v", err)
}
cfg.Distribution.Auth.ClientPolicy.AllowedUpstreams = []string{"missing"}
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "missing") {
t.Fatalf("Validate(unknown allowed upstream) error = %v", err)
}
cfg.Distribution.Auth.ClientPolicy = clientpolicy.Policy{}
cfg.Gateway.Auth = Auth{
Mode: "bearer", Token: "gateway-token",
ClientPolicy: clientpolicy.Policy{AllowedRoutings: []string{"extract"}},
}
cfg.Routing[0].Purpose = "gateway"
if err := Validate(cfg); err != nil {
t.Fatalf("Validate(gateway routing policy) error = %v", err)
}
cfg.Gateway.Auth.ClientPolicy.AllowedRoutings = []string{"missing"}
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "missing") {
t.Fatalf("Validate(unknown gateway routing) error = %v", err)
}
cfg.Gateway.Auth.ClientPolicy = clientpolicy.Policy{MaxExtractCount: 1}
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "only supported on distribution") {
t.Fatalf("Validate(gateway extraction policy) error = %v", err)
}
}
func TestValidateGatewayStickySession(t *testing.T) {
t.Parallel()
cfg := mustLoadValidConfig(t)
cfg.Gateway.Auth = Auth{Mode: "bearer", Token: "gateway-token"}
cfg.Gateway.StickySession = StickySession{
Enabled: true, Header: "X-Proxy-Session", TTL: Duration(30 * time.Second), MaxEntries: 10_000,
}
cfg.Routing[0].Purpose = "gateway"
if err := Validate(cfg); err != nil {
t.Fatalf("Validate(valid gateway stickySession) error = %v", err)
}
cfg.Gateway.StickySession.Header = ""
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "stickySession.header") {
t.Fatalf("Validate(missing stickySession header) error = %v", err)
}
cfg.Gateway.StickySession.Header = "X Proxy Session"
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "stickySession.header") {
t.Fatalf("Validate(invalid stickySession header) error = %v", err)
}
cfg.Gateway.StickySession.Header = "X-Proxy-Session"
cfg.Gateway.StickySession.TTL = 0
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "stickySession.ttl") {
t.Fatalf("Validate(zero stickySession ttl) error = %v", err)
}
cfg.Gateway.StickySession.TTL = Duration(30 * time.Second)
cfg.Gateway.Auth = Auth{Mode: "none"}
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "requires authentication") {
t.Fatalf("Validate(unauthenticated stickySession) error = %v", err)
}
cfg.Gateway.Auth = Auth{Mode: "bearer", Token: "gateway-token"}
cfg.Distribution.StickySession = StickySession{
Enabled: true, Header: "X-Proxy-Session", TTL: Duration(30 * time.Second), MaxEntries: 10,
}
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "only supported on gateway") {
t.Fatalf("Validate(distribution stickySession) error = %v", err)
}
}
func TestValidateMetricsListener(t *testing.T) {
t.Parallel()
cfg := mustLoadValidConfig(t)
cfg.Metrics = Metrics{Enabled: true}
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "metrics listen") {
t.Fatalf("Validate(metrics without listen) error = %v", err)
}
cfg.Metrics.Listen = "not-an-address"
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "metrics listen") {
t.Fatalf("Validate(invalid metrics listen) error = %v", err)
}
cfg.Metrics.Listen = "127.0.0.1:70000"
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "metrics listen") {
t.Fatalf("Validate(out-of-range metrics port) error = %v", err)
}
cfg.Metrics.Listen = "0.0.0.0:9090"
if err := Validate(cfg); err != nil {
t.Fatalf("Validate(public metrics listener) error = %v", err)
}
cfg.Metrics = Metrics{Enabled: false, Listen: "not-an-address"}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate(disabled metrics listener) error = %v", err)
}
}
func TestValidateControlPlane(t *testing.T) {
tests := []struct {
name string
mutate func(*Config)
want string
}{
{
name: "missing listen",
mutate: func(cfg *Config) {
cfg.ControlPlane = validControlPlane()
cfg.ControlPlane.Listen = ""
},
want: "controlPlane listen",
},
{
name: "public plaintext",
mutate: func(cfg *Config) {
cfg.ControlPlane = validControlPlane()
cfg.ControlPlane.Listen = "0.0.0.0:8443"
},
want: "requires mtls",
},
{
name: "short session ttl",
mutate: func(cfg *Config) {
cfg.ControlPlane = validControlPlane()
cfg.ControlPlane.SessionTTL = Duration(29 * time.Second)
},
want: "sessionTTL",
},
{
name: "stale below heartbeat",
mutate: func(cfg *Config) {
cfg.ControlPlane = validControlPlane()
cfg.ControlPlane.MaxStaleAge = Duration(9 * time.Second)
},
want: "maxStaleAge",
},
{
name: "invalid protocol",
mutate: func(cfg *Config) {
cfg.ControlPlane = validControlPlane()
cfg.ControlPlane.ProtocolVersion = 0
},
want: "protocolVersion",
},
{
name: "invalid message limit",
mutate: func(cfg *Config) {
cfg.ControlPlane = validControlPlane()
cfg.ControlPlane.MaxMessageBytes = 64<<20 + 1
},
want: "maxMessageBytes",
},
{
name: "invalid counter limit",
mutate: func(cfg *Config) {
cfg.ControlPlane = validControlPlane()
cfg.ControlPlane.MaxRuntimeCounters = MaximumPoolSize + 1
},
want: "maxRuntimeCounters",
},
{
name: "zero concurrent streams",
mutate: func(cfg *Config) {
cfg.ControlPlane = validControlPlane()
cfg.ControlPlane.MaxConcurrentStreams = 0
},
want: "maxConcurrentStreams",
},
{
name: "unsupported tls mode",
mutate: func(cfg *Config) {
cfg.ControlPlane = validControlPlane()
cfg.ControlPlane.TLS.Mode = "serverTLS"
},
want: "tls.mode",
},
{
name: "missing mtls files",
mutate: func(cfg *Config) {
cfg.ControlPlane = validMTLSControlPlane()
cfg.ControlPlane.TLS.CertFile = ""
},
want: "certFile",
},
{
name: "trust domain with port",
mutate: func(cfg *Config) {
cfg.ControlPlane = validMTLSControlPlane()
cfg.ControlPlane.TLS.TrustDomain = "proxy.example:443"
},
want: "trustDomain",
},
{
name: "environment path",
mutate: func(cfg *Config) {
cfg.ControlPlane = validMTLSControlPlane()
cfg.ControlPlane.TLS.Environment = "prod/eu"
},
want: "environment",
},
{
name: "partial gateway tls",
mutate: func(cfg *Config) {
cfg.ControlPlane = validMTLSControlPlane()
cfg.ControlPlane.GatewayTLS.CertFile = "/run/secrets/gateway-cert.pem"
},
want: "gatewayTLS",
},
{
name: "partial checker tls",
mutate: func(cfg *Config) {
cfg.ControlPlane = validMTLSControlPlane()
cfg.ControlPlane.CheckerTLS.KeyFile = "/run/secrets/checker-key.pem"
},
want: "checkerTLS",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := mustLoadValidConfig(t)
test.mutate(cfg)
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Validate() error = %v, want substring %q", err, test.want)
}
})
}
}
func validControlPlane() ControlPlane {
return ControlPlane{
Enabled: true,
Listen: "127.0.0.1:8443",
ProtocolVersion: 1,
HeartbeatInterval: Duration(10 * time.Second),
SessionTTL: Duration(30 * time.Second),
MaxStaleAge: Duration(10 * time.Second),
MaxMessageBytes: 1 << 20,
MaxRuntimeCounters: 100_000,
MaxConcurrentStreams: 128,
TLS: ControlPlaneTLS{Mode: "disabled"},
}
}
func validMTLSControlPlane() ControlPlane {
controlPlane := validControlPlane()
controlPlane.Listen = "0.0.0.0:8443"
controlPlane.TLS = ControlPlaneTLS{
Mode: "mtls",
CertFile: "/run/secrets/controller-cert.pem",
KeyFile: "/run/secrets/controller-key.pem",
ClientCAFile: "/run/secrets/worker-ca.pem",
TrustDomain: "proxy.example",
Environment: "production",
}
return controlPlane
}
func TestValidateRejectsInvalidConfigurationMatrix(t *testing.T) {
tests := []struct {
name string
mutate func(*Config)
want string
}{
{
name: "unsupported strategy type",
mutate: func(cfg *Config) {
cfg.Routing[0].Strategy.Type = "custom"
},
want: "strategy.type",
},
{
name: "unsupported unavailable action",
mutate: func(cfg *Config) {
cfg.Routing[0].OnUnavailable.Action = "fallback"
},
want: "onUnavailable.action",
},
{
name: "negative cumulative fetch limit",
mutate: func(cfg *Config) {
upstream := cfg.Upstreams["provider-a"]
upstream.Fetch.MaxTotal = -1
cfg.Upstreams["provider-a"] = upstream
},
want: "fetch.maxTotal",
},
{
name: "negative gateway reserve",
mutate: func(cfg *Config) {
cfg.Distribution.Extraction.ReserveForGateway = -1
},
want: "reserveForGateway",
},
{
name: "negative extraction idempotency ttl",
mutate: func(cfg *Config) {
cfg.Distribution.Extraction.IdempotencyTTL = Duration(-time.Second)
},
want: "idempotencyTTL",
},
{
name: "enabled upstream without ttl",
mutate: func(cfg *Config) {
upstream := cfg.Upstreams["provider-a"]
upstream.Lifecycle.TTL = 0
cfg.Upstreams["provider-a"] = upstream
},
want: "lifecycle.ttl",
},
{
name: "no enabled upstream",
mutate: func(cfg *Config) {
for name, upstream := range cfg.Upstreams {
upstream.Enabled = false
cfg.Upstreams[name] = upstream
}
},
want: "enabled upstream",
},
{
name: "invalid trusted proxy CIDR",
mutate: func(cfg *Config) {
cfg.Gateway.Access.TrustedProxies = []string{"not-a-cidr"}
},
want: "trustedProxies",
},
{
name: "negative listener request limit",
mutate: func(cfg *Config) {
cfg.Distribution.Limits.RequestsPerMinute = -1
},
want: "requestsPerMinute",
},
{
name: "listener request limit exceeds exact counter range",
mutate: func(cfg *Config) {
cfg.Distribution.Limits.RequestsPerMinutePerClient = int(MaximumExactCounter) + 1
},
want: "requestsPerMinutePerClient",
},
{
name: "invalid client identification mode",
mutate: func(cfg *Config) {
cfg.Distribution.ClientIdentification.Mode = "header"
},
want: "clientIdentification.mode",
},
{
name: "authenticated client without authentication",
mutate: func(cfg *Config) {
cfg.Distribution.Auth = Auth{Mode: "none"}
cfg.Distribution.ClientIdentification.Mode = "authenticatedClient"
},
want: "authenticatedClient requires authentication",
},
{
name: "invalid destination deny CIDR",
mutate: func(cfg *Config) {
cfg.Gateway.DestinationPolicy.DenyCIDRs = []string{"not-a-cidr"}
},
want: "denyCIDRs",
},
{
name: "invalid destination allowed port",
mutate: func(cfg *Config) {
cfg.Gateway.DestinationPolicy.AllowedPorts = []uint16{443, 0}
},
want: "allowedPorts",
},
{
name: "duplicate destination allowed port",
mutate: func(cfg *Config) {
cfg.Gateway.DestinationPolicy.AllowedPorts = []uint16{443, 443}
},
want: "allowedPorts",
},
{
name: "unsupported routing purpose",
mutate: func(cfg *Config) {
cfg.Routing[0].Purpose = "custom"
},
want: "purpose",
},
{
name: "unsupported sequential end behavior",
mutate: func(cfg *Config) {
cfg.Routing[0].Strategy.EndBehavior = "restart"
},
want: "endBehavior",
},
{
name: "sequential requires two upstreams",
mutate: func(cfg *Config) {
cfg.Routing[0].Upstreams = []string{"provider-a"}
},
want: "at least two upstreams",
},
{
name: "weighted strategy missing weight",
mutate: func(cfg *Config) {
cfg.Routing[0].Strategy = Strategy{Type: "weighted", Weights: map[string]int{}}
},
want: "weights",
},
{
name: "weighted strategy unknown upstream",
mutate: func(cfg *Config) {
cfg.Routing[0].Strategy = Strategy{Type: "weighted", Weights: map[string]int{
"provider-a": 1,
"provider-b": 1,
"provider-c": 1,
}}
},
want: "provider-c",
},
{
name: "weighted strategy nonpositive weight",
mutate: func(cfg *Config) {
cfg.Routing[0].Strategy = Strategy{Type: "weighted", Weights: map[string]int{"provider-a": 0}}
},
want: "weight",
},
{
name: "negative fetch request interval",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Fetch.RequestInterval = Duration(-1) })
},
want: "requestInterval",
},
{
name: "zero fetch timeout",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Fetch.Timeout = 0 })
},
want: "fetch.timeout",
},
{
name: "zero fetch attempts",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Fetch.MaxAttempts = 0 })
},
want: "fetch.maxAttempts",
},
{
name: "zero fetch concurrency",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Fetch.MaxInFlight = 0 })
},
want: "fetch.maxInFlight",
},
{
name: "zero estimated IPs per call",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Fetch.EstimatedIPsPerCall = 0 })
},
want: "estimatedIPsPerCall",
},
{
name: "estimated IPs exceed pool size",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) {
upstream.Fetch.EstimatedIPsPerCall = upstream.Pool.MaxSize + 1
})
},
want: "estimatedIPsPerCall",
},
{
name: "pool exceeds runtime scan bound",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Pool.MaxSize = MaximumPoolSize + 1 })
},
want: "pool.maxSize",
},
{
name: "zero refill interval",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Refill.ReconcileInterval = 0 })
},
want: "refill.reconcileInterval",
},
{
name: "refill target does not exceed minimum",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) {
upstream.Refill.TargetAvailableSlots = upstream.Refill.MinimumAvailableSlots
})
},
want: "targetAvailableSlots",
},
{
name: "refill target exceeds theoretical capacity",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) {
upstream.Refill.TargetAvailableSlots = int64(upstream.Pool.MaxSize)*int64(upstream.Capacity.MaxConcurrencyPerProxy) + 1
})
},
want: "targetAvailableSlots",
},
{
name: "negative fetch response limit",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Fetch.MaxResponseBytes = -1 })
},
want: "maxResponseBytes",
},
{
name: "negative template timeout",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Fetch.TemplateTimeout = Duration(-1) })
},
want: "templateTimeout",
},
{
name: "fetch jitter over one hundred",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Fetch.Retry.Jitter = 101 })
},
want: "fetch.retry.jitter",
},
{
name: "zero check interval",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Check.Interval = 0 })
},
want: "check.interval",
},
{
name: "check jitter over one hundred",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Check.Jitter = 101 })
},
want: "check.jitter",
},
{
name: "zero check concurrency",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Check.MaxInFlight = 0 })
},
want: "check.maxInFlight",
},
{
name: "zero check timeout",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Check.Timeout = 0 })
},
want: "check.timeout",
},
{
name: "zero check attempts",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Check.MaxAttempts = 0 })
},
want: "check.maxAttempts",
},
{
name: "zero check failure threshold",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Check.MaxConsecutiveFailures = 0 })
},
want: "maxConsecutiveFailures",
},
{
name: "negative unhealthy remove after",
mutate: func(cfg *Config) {
updateUpstream(cfg, func(upstream *Upstream) { upstream.Check.UnhealthyRemoveAfter = Duration(-time.Second) })
},
want: "unhealthyRemoveAfter",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := mustLoadValidConfig(t)
test.mutate(cfg)
err := Validate(cfg)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Validate() error = %v, want substring %q", err, test.want)
}
})
}
}
func TestValidateRejectsUnboundedUpstreamCardinality(t *testing.T) {
cfg := mustLoadValidConfig(t)
for index := len(cfg.Upstreams); index <= MaximumUpstreams; index++ {
cfg.Upstreams[fmt.Sprintf("disabled-%d", index)] = Upstream{}
}
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "upstream count") {
t.Fatalf("Validate(too many upstreams) error = %v", err)
}
}
func TestValidateRejectsCountersOutsideRedisExactRange(t *testing.T) {
if strconv.IntSize < 64 {
t.Skip("64-bit int is required for values above the Redis exact range")
}
tests := []struct {
name string
mutate func(*Upstream)
}{
{name: "fetch total", mutate: func(upstream *Upstream) {
upstream.Fetch.MaxTotal = int(MaximumExactCounter) + 1
}},
{name: "proxy concurrency", mutate: func(upstream *Upstream) {
upstream.Capacity.MaxConcurrencyPerProxy = int(MaximumExactCounter) + 1
}},
{name: "minimum slots", mutate: func(upstream *Upstream) {
upstream.Refill.MinimumAvailableSlots = MaximumExactCounter + 1
upstream.Refill.TargetAvailableSlots = MaximumExactCounter + 2
}},
{name: "target slots", mutate: func(upstream *Upstream) {
upstream.Capacity.MaxConcurrencyPerProxy = int(MaximumExactCounter)
upstream.Pool.MaxSize = 1
upstream.Refill.MinimumAvailableSlots = MaximumExactCounter
upstream.Refill.TargetAvailableSlots = MaximumExactCounter + 1
}},
{name: "theoretical slots", mutate: func(upstream *Upstream) {
upstream.Pool.MaxSize = 2
upstream.Capacity.MaxConcurrencyPerProxy = int(MaximumExactCounter/2 + 1)
upstream.Refill.MinimumAvailableSlots = 1
upstream.Refill.TargetAvailableSlots = 2
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cfg := mustLoadValidConfig(t)
updateUpstream(cfg, test.mutate)
if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "exact counter range") {
t.Fatalf("Validate(inexact counter) error = %v", err)
}
})
}
}
func TestResolvedConfigFormattingRedactsSecrets(t *testing.T) {
configured := strings.Replace(validConfig, ` auth:
mode: none`, ` auth:
mode: usernamePassword
username: gateway-user
password: "${GATEWAY_PASSWORD}"`, 1)
configured = strings.Replace(configured, ` auth:
mode: none`, ` auth:
mode: apiKey
header: X-API-Key
token: "${DISTRIBUTION_TOKEN}"`, 1)
configured = strings.Replace(configured, ` auth:
type: none`, ` auth:
type: apiKey
location: header
name: X-Provider-Key
value: "${PROVIDER_API_KEY}"`, 1)
resolver := fixtureResolver{environment: map[string]string{
"GATEWAY_PASSWORD": "password-marker",
"DISTRIBUTION_TOKEN": "token-marker",
"PROVIDER_API_KEY": "api-key-marker",
}}
cfg, err := LoadResolved(strings.NewReader(configured), resolver)
if err != nil {
t.Fatalf("LoadResolved(): %v", err)
}
for _, format := range []string{"%v", "%+v", "%#v"} {
formatted := fmt.Sprintf(format, cfg)
for _, secret := range []string{"password-marker", "token-marker", "api-key-marker"} {
if strings.Contains(formatted, secret) {
t.Fatalf("format %q leaked secret %q: %s", format, secret, formatted)
}
}
if !strings.Contains(formatted, "[REDACTED]") {
t.Fatalf("format %q did not contain a redaction marker: %s", format, formatted)
}
}
redacted := cfg.Redacted()
if redacted.Gateway.Auth.Password != "[REDACTED]" ||
redacted.Distribution.Auth.Token != "[REDACTED]" ||
redacted.Upstreams["provider-a"].API.Auth.Value != "[REDACTED]" {
t.Fatalf("Redacted() retained a secret: %+v", &redacted)
}
if cfg.Gateway.Auth.Password != "password-marker" {
t.Fatal("Redacted() mutated the source configuration")
}
}
func mustLoadValidConfig(t *testing.T) *Config {
t.Helper()
cfg, err := Load(strings.NewReader(validConfig))
if err != nil {
t.Fatalf("Load(validConfig): %v", err)
}
return cfg
}
func updateUpstream(cfg *Config, update func(*Upstream)) {
upstream := cfg.Upstreams["provider-a"]
update(&upstream)
cfg.Upstreams["provider-a"] = upstream
}
type fixtureResolver struct {
environment map[string]string
files map[string]string
}
func (r fixtureResolver) LookupEnv(name string) (string, bool) {
value, ok := r.environment[name]
return value, ok
}
func (r fixtureResolver) ReadFile(path string) ([]byte, error) {
value, ok := r.files[path]
if !ok {
return nil, fmt.Errorf("fixture file %q not found", path)
}
return []byte(value), nil
}