proxy-pool/internal/config/config_test.go

640 lines
18 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"go.yaml.in/yaml/v4"
)
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]
strategy:
type: sequential
switchAfterEmptyFetch: 5
endBehavior: stayLast
onUnavailable:
action: reject
upstreams:
provider-a:
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
lifecycle:
ttl: 120s
allocationSafetyMargin: 10s
fetch:
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]
`
func TestLoadStrictValidConfiguration(t *testing.T) {
cfg, err := Load(strings.NewReader(validConfig))
if err != nil {
t.Fatalf("Load(): %v", err)
}
if cfg.Version != 1 || cfg.Upstreams["provider-a"].Pool.MaxSize != 100 {
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]", "upstreams: [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 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: "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: "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: "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,
}}
},
want: "provider-b",
},
{
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: "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",
},
}
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 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
}