494 lines
13 KiB
Go
494 lines
13 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
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 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 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 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: "invalid destination deny CIDR",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Gateway.DestinationPolicy.DenyCIDRs = []string{"not-a-cidr"}
|
|
},
|
|
want: "denyCIDRs",
|
|
},
|
|
{
|
|
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
|
|
}
|