1012 lines
29 KiB
Go
1012 lines
29 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.yaml.in/yaml/v4"
|
|
|
|
"proxy-pool/internal/domain/authorization"
|
|
)
|
|
|
|
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 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
|
|
}
|