dbb72da312
Add explicit server timeout/header limits, require trusted proxy CIDRs when honoring forwarded IP headers, and document single-instance storage expectations. Co-authored-by: Cursor <cursoragent@cursor.com>
388 lines
10 KiB
Go
388 lines
10 KiB
Go
package config
|
|
|
|
import (
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := defaults()
|
|
cfg.Storage.DataDir = t.TempDir()
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
t.Fatalf("Validate() returned error: %v", err)
|
|
}
|
|
|
|
if cfg.Limits.MaxUploadSizeBytes != 100*1024*1024 {
|
|
t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(100*1024*1024))
|
|
}
|
|
if cfg.Server.ReadHeaderTimeoutDur != 5*time.Second {
|
|
t.Fatalf("ReadHeaderTimeoutDur = %s, want 5s", cfg.Server.ReadHeaderTimeoutDur)
|
|
}
|
|
if cfg.Server.ReadTimeoutDur != 30*time.Second {
|
|
t.Fatalf("ReadTimeoutDur = %s, want 30s", cfg.Server.ReadTimeoutDur)
|
|
}
|
|
if cfg.Server.WriteTimeoutDur != 30*time.Second {
|
|
t.Fatalf("WriteTimeoutDur = %s, want 30s", cfg.Server.WriteTimeoutDur)
|
|
}
|
|
if cfg.Server.IdleTimeoutDur != 120*time.Second {
|
|
t.Fatalf("IdleTimeoutDur = %s, want 120s", cfg.Server.IdleTimeoutDur)
|
|
}
|
|
if cfg.Server.MaxHeaderBytes != 1<<20 {
|
|
t.Fatalf("MaxHeaderBytes = %d, want %d", cfg.Server.MaxHeaderBytes, 1<<20)
|
|
}
|
|
if cfg.Limits.DefaultTTLDuration != time.Hour {
|
|
t.Fatalf("DefaultTTLDuration = %s, want 1h", cfg.Limits.DefaultTTLDuration)
|
|
}
|
|
if cfg.Limits.RawCacheMaxBytes != 200*1024*1024 {
|
|
t.Fatalf("RawCacheMaxBytes = %d, want %d", cfg.Limits.RawCacheMaxBytes, int64(200*1024*1024))
|
|
}
|
|
if cfg.Limits.RawCacheMaxEntries != 32 {
|
|
t.Fatalf("RawCacheMaxEntries = %d, want %d", cfg.Limits.RawCacheMaxEntries, 32)
|
|
}
|
|
if cfg.Storage.CleanupIntervalParsed != time.Minute {
|
|
t.Fatalf("CleanupIntervalParsed = %s, want 1m", cfg.Storage.CleanupIntervalParsed)
|
|
}
|
|
if len(cfg.Security.AllowedPrefixes) != 1 {
|
|
t.Fatalf("AllowedPrefixes len = %d, want 1", len(cfg.Security.AllowedPrefixes))
|
|
}
|
|
if got := cfg.Security.AllowedPrefixes[0]; got != netip.MustParsePrefix("127.0.0.1/32") {
|
|
t.Fatalf("AllowedPrefixes[0] = %v, want 127.0.0.1/32", got)
|
|
}
|
|
}
|
|
|
|
func TestLoadParsesConfiguredFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmp := t.TempDir()
|
|
dataDir := filepath.Join(tmp, "store")
|
|
configPath := filepath.Join(tmp, "config.yaml")
|
|
content := `
|
|
server:
|
|
listen_addr: "127.0.0.1:9090"
|
|
read_header_timeout: "4s"
|
|
read_timeout: "25s"
|
|
write_timeout: "35s"
|
|
idle_timeout: "90s"
|
|
max_header_bytes: 2097152
|
|
limits:
|
|
max_upload_size: "2MiB"
|
|
default_ttl: "2h"
|
|
raw_cache_max_size: "16MiB"
|
|
raw_cache_max_entries: 5
|
|
storage:
|
|
data_dir: "` + dataDir + `"
|
|
cleanup_interval: "45s"
|
|
security:
|
|
allowed_ips:
|
|
- "127.0.0.1"
|
|
- "10.0.0.0/8"
|
|
trust_proxy_headers: true
|
|
trusted_proxy_ips:
|
|
- "10.0.0.0/8"
|
|
rate_limit:
|
|
enabled: true
|
|
requests_per_minute: 12
|
|
burst: 4
|
|
`
|
|
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("WriteFile() error = %v", err)
|
|
}
|
|
|
|
cfg, err := Load(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
|
|
if cfg.Server.ListenAddr != "127.0.0.1:9090" {
|
|
t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, "127.0.0.1:9090")
|
|
}
|
|
if cfg.Server.ReadHeaderTimeoutDur != 4*time.Second {
|
|
t.Fatalf("ReadHeaderTimeoutDur = %s, want 4s", cfg.Server.ReadHeaderTimeoutDur)
|
|
}
|
|
if cfg.Server.ReadTimeoutDur != 25*time.Second {
|
|
t.Fatalf("ReadTimeoutDur = %s, want 25s", cfg.Server.ReadTimeoutDur)
|
|
}
|
|
if cfg.Server.WriteTimeoutDur != 35*time.Second {
|
|
t.Fatalf("WriteTimeoutDur = %s, want 35s", cfg.Server.WriteTimeoutDur)
|
|
}
|
|
if cfg.Server.IdleTimeoutDur != 90*time.Second {
|
|
t.Fatalf("IdleTimeoutDur = %s, want 90s", cfg.Server.IdleTimeoutDur)
|
|
}
|
|
if cfg.Server.MaxHeaderBytes != 2*1024*1024 {
|
|
t.Fatalf("MaxHeaderBytes = %d, want %d", cfg.Server.MaxHeaderBytes, 2*1024*1024)
|
|
}
|
|
if cfg.Limits.MaxUploadSizeBytes != 2*1024*1024 {
|
|
t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(2*1024*1024))
|
|
}
|
|
if cfg.Limits.DefaultTTLDuration != 2*time.Hour {
|
|
t.Fatalf("DefaultTTLDuration = %s, want 2h", cfg.Limits.DefaultTTLDuration)
|
|
}
|
|
if cfg.Limits.RawCacheMaxBytes != 16*1024*1024 {
|
|
t.Fatalf("RawCacheMaxBytes = %d, want %d", cfg.Limits.RawCacheMaxBytes, int64(16*1024*1024))
|
|
}
|
|
if cfg.Limits.RawCacheMaxEntries != 5 {
|
|
t.Fatalf("RawCacheMaxEntries = %d, want %d", cfg.Limits.RawCacheMaxEntries, 5)
|
|
}
|
|
if cfg.Storage.CleanupIntervalParsed != 45*time.Second {
|
|
t.Fatalf("CleanupIntervalParsed = %s, want 45s", cfg.Storage.CleanupIntervalParsed)
|
|
}
|
|
if !cfg.Security.TrustProxyHeaders {
|
|
t.Fatalf("TrustProxyHeaders = false, want true")
|
|
}
|
|
if cfg.Security.RateLimit.RequestsPerMinute != 12 || cfg.Security.RateLimit.Burst != 4 {
|
|
t.Fatalf("unexpected rate_limit values: %+v", cfg.Security.RateLimit)
|
|
}
|
|
if len(cfg.Security.AllowedPrefixes) != 2 {
|
|
t.Fatalf("AllowedPrefixes len = %d, want 2", len(cfg.Security.AllowedPrefixes))
|
|
}
|
|
if len(cfg.Security.TrustedProxyPrefixes) != 1 {
|
|
t.Fatalf("TrustedProxyPrefixes len = %d, want 1", len(cfg.Security.TrustedProxyPrefixes))
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsInvalidValues(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*Config)
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "listen addr required",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Server.ListenAddr = " "
|
|
},
|
|
wantErr: "server.listen_addr",
|
|
},
|
|
{
|
|
name: "invalid max upload size",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Limits.MaxUploadSize = "oops"
|
|
},
|
|
wantErr: "limits.max_upload_size",
|
|
},
|
|
{
|
|
name: "invalid read header timeout",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Server.ReadHeaderTimeout = "bogus"
|
|
},
|
|
wantErr: "server.read_header_timeout",
|
|
},
|
|
{
|
|
name: "invalid read timeout",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Server.ReadTimeout = "bogus"
|
|
},
|
|
wantErr: "server.read_timeout",
|
|
},
|
|
{
|
|
name: "invalid write timeout",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Server.WriteTimeout = "bogus"
|
|
},
|
|
wantErr: "server.write_timeout",
|
|
},
|
|
{
|
|
name: "invalid idle timeout",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Server.IdleTimeout = "bogus"
|
|
},
|
|
wantErr: "server.idle_timeout",
|
|
},
|
|
{
|
|
name: "invalid max header bytes",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Server.MaxHeaderBytes = 0
|
|
},
|
|
wantErr: "server.max_header_bytes",
|
|
},
|
|
{
|
|
name: "invalid ttl parse",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Limits.DefaultTTL = "not-a-duration"
|
|
},
|
|
wantErr: "limits.default_ttl",
|
|
},
|
|
{
|
|
name: "invalid raw cache size",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Limits.RawCacheMaxSize = "oops"
|
|
},
|
|
wantErr: "limits.raw_cache_max_size",
|
|
},
|
|
{
|
|
name: "invalid raw cache max entries",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Limits.RawCacheMaxEntries = 0
|
|
},
|
|
wantErr: "limits.raw_cache_max_entries",
|
|
},
|
|
{
|
|
name: "ttl above max",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Limits.DefaultTTL = "25h"
|
|
},
|
|
wantErr: "limits.default_ttl",
|
|
},
|
|
{
|
|
name: "invalid allowlist entry",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Security.AllowedIPs = []string{"not-an-ip"}
|
|
},
|
|
wantErr: "security.allowed_ips",
|
|
},
|
|
{
|
|
name: "invalid trusted proxy entry",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Security.TrustedProxyIPs = []string{"not-an-ip"}
|
|
},
|
|
wantErr: "security.trusted_proxy_ips",
|
|
},
|
|
{
|
|
name: "trust proxy headers requires trusted proxies",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Security.TrustProxyHeaders = true
|
|
cfg.Security.TrustedProxyIPs = nil
|
|
},
|
|
wantErr: "security.trusted_proxy_ips is required",
|
|
},
|
|
{
|
|
name: "invalid rate limit burst",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Security.RateLimit.Burst = 0
|
|
},
|
|
wantErr: "security.rate_limit.burst",
|
|
},
|
|
{
|
|
name: "data dir required",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Storage.DataDir = " "
|
|
},
|
|
wantErr: "storage.data_dir",
|
|
},
|
|
{
|
|
name: "cleanup parse failure",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Storage.CleanupInterval = "not-a-duration"
|
|
},
|
|
wantErr: "storage.cleanup_interval",
|
|
},
|
|
{
|
|
name: "cleanup too small",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Storage.CleanupInterval = "5s"
|
|
},
|
|
wantErr: "storage.cleanup_interval",
|
|
},
|
|
{
|
|
name: "invalid rate limit rpm",
|
|
mutate: func(cfg *Config) {
|
|
cfg.Security.RateLimit.RequestsPerMinute = 0
|
|
},
|
|
wantErr: "security.rate_limit.requests_per_minute",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := defaults()
|
|
cfg.Storage.DataDir = t.TempDir()
|
|
tc.mutate(&cfg)
|
|
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatalf("Validate() error = nil, want non-nil")
|
|
}
|
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := Load(filepath.Join(t.TempDir(), "missing.yaml"))
|
|
if err == nil || !strings.Contains(err.Error(), "read config") {
|
|
t.Fatalf("Load() error = %v, want read config error", err)
|
|
}
|
|
|
|
tmp := t.TempDir()
|
|
configPath := filepath.Join(tmp, "bad.yaml")
|
|
if err := os.WriteFile(configPath, []byte("limits: [bad"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile() error = %v", err)
|
|
}
|
|
_, err = Load(configPath)
|
|
if err == nil || !strings.Contains(err.Error(), "parse config yaml") {
|
|
t.Fatalf("Load() error = %v, want yaml parse error", err)
|
|
}
|
|
}
|
|
|
|
func TestParseAllowedPrefixesTrimsAndParses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
prefixes, err := parseAllowedPrefixes([]string{" 192.0.2.4 ", " ", "2001:db8::/32"})
|
|
if err != nil {
|
|
t.Fatalf("parseAllowedPrefixes() error = %v", err)
|
|
}
|
|
if len(prefixes) != 2 {
|
|
t.Fatalf("prefixes len = %d, want 2", len(prefixes))
|
|
}
|
|
wantIP := netip.MustParsePrefix("192.0.2.4/32")
|
|
if prefixes[0] != wantIP {
|
|
t.Fatalf("prefixes[0] = %v, want %v", prefixes[0], wantIP)
|
|
}
|
|
}
|
|
|
|
func TestParseHumanSizeVariants(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
in string
|
|
want int64
|
|
wantErr string
|
|
}{
|
|
{in: "10", want: 10},
|
|
{in: "1.5KB", want: 1500},
|
|
{in: "2MiB", want: 2 * 1024 * 1024},
|
|
{in: "1TB", wantErr: "unsupported unit"},
|
|
{in: "1TiB", wantErr: "unsupported unit"},
|
|
{in: "0", wantErr: "size must be > 0"},
|
|
{in: "abc", wantErr: "does not start with a number"},
|
|
{in: "5XB", wantErr: "unsupported unit"},
|
|
{in: "", wantErr: "size is empty"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.in, func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := parseHumanSize(tc.in)
|
|
if tc.wantErr != "" {
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("parseHumanSize(%q) err=%v, want contains %q", tc.in, err, tc.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("parseHumanSize(%q) error = %v", tc.in, err)
|
|
}
|
|
if got != tc.want {
|
|
t.Fatalf("parseHumanSize(%q) = %d, want %d", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|