Files
scratchbox/internal/config/config_test.go
T
s1d3sw1ped 90eb56465b
Format / gofmt (push) Successful in 1m17s
CI / Build (push) Successful in 1m31s
CI / Go Tests (push) Successful in 1m36s
Enhance environment variable documentation and configuration
- Updated `.env.example` to include new environment variables for server access logging and security settings, ensuring comprehensive configuration options.
- Revised `docker-entrypoint.sh` to clarify the source of supported environment variables.
- Improved `README.md` to reflect the latest environment variable changes and maintain synchronization with the canonical list in `internal/config/config.go`.
- Added tests to ensure documentation consistency across `.env.example` and `README.md`, preventing future discrepancies.
2026-07-15 16:24:55 -05:00

794 lines
25 KiB
Go

package config
import (
"encoding/base64"
"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 err := cfg.PrepareDataDir(); err != nil {
t.Fatalf("PrepareDataDir() 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.ShutdownTimeoutDur != 10*time.Second {
t.Fatalf("ShutdownTimeoutDur = %s, want 10s", cfg.Server.ShutdownTimeoutDur)
}
if cfg.Server.MaxHeaderBytes != 1<<20 {
t.Fatalf("MaxHeaderBytes = %d, want %d", cfg.Server.MaxHeaderBytes, 1<<20)
}
if !cfg.Server.AccessLog.Enabled {
t.Fatalf("AccessLog.Enabled = %v, want true", cfg.Server.AccessLog.Enabled)
}
if cfg.Server.AccessLog.MaxSizeBytes != 100*1024*1024 {
t.Fatalf("AccessLog.MaxSizeBytes = %d, want %d", cfg.Server.AccessLog.MaxSizeBytes, int64(100*1024*1024))
}
if cfg.Server.AccessLog.MaxBackups != 5 {
t.Fatalf("AccessLog.MaxBackups = %d, want 5", cfg.Server.AccessLog.MaxBackups)
}
if cfg.Limits.DefaultTTLDuration != 15*time.Minute {
t.Fatalf("DefaultTTLDuration = %s, want 15m", 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.Storage.EncryptionKeyBytes) != 32 {
t.Fatalf("EncryptionKeyBytes length = %d, want 32", len(cfg.Storage.EncryptionKeyBytes))
}
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)
}
if !cfg.Security.HSTSEnabled {
t.Fatalf("HSTSEnabled = false, want true (default)")
}
}
func TestValidateAllowsZeroReadAndWriteTimeout(t *testing.T) {
t.Parallel()
cfg := defaults()
cfg.Storage.DataDir = t.TempDir()
// 0 disables the deadline (net/http semantics). Needed for multi-GiB uploads
// where a fixed 30s body/response timeout cannot work.
cfg.Server.ReadTimeout = "0s"
cfg.Server.WriteTimeout = "0"
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() returned error: %v", err)
}
if cfg.Server.ReadTimeoutDur != 0 {
t.Fatalf("ReadTimeoutDur = %s, want 0", cfg.Server.ReadTimeoutDur)
}
if cfg.Server.WriteTimeoutDur != 0 {
t.Fatalf("WriteTimeoutDur = %s, want 0", cfg.Server.WriteTimeoutDur)
}
}
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
access_log:
enabled: true
file_path: "` + filepath.Join(tmp, "access.log") + `"
max_size: "2MiB"
max_backups: 7
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_ui:
enabled: true
requests_per_minute: 12
burst: 4
rate_limit_api_read:
enabled: true
requests_per_minute: 12
burst: 4
rate_limit_api_write:
enabled: true
requests_per_minute: 12
burst: 4
hsts_enabled: false
`
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 err := cfg.PrepareDataDir(); err != nil {
t.Fatalf("PrepareDataDir() 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.ShutdownTimeoutDur != 10*time.Second {
t.Fatalf("ShutdownTimeoutDur = %s, want 10s", cfg.Server.ShutdownTimeoutDur)
}
if cfg.Server.MaxHeaderBytes != 2*1024*1024 {
t.Fatalf("MaxHeaderBytes = %d, want %d", cfg.Server.MaxHeaderBytes, 2*1024*1024)
}
if !cfg.Server.AccessLog.Enabled {
t.Fatalf("AccessLog.Enabled = false, want true")
}
if cfg.Server.AccessLog.MaxSizeBytes != 2*1024*1024 {
t.Fatalf("AccessLog.MaxSizeBytes = %d, want %d", cfg.Server.AccessLog.MaxSizeBytes, int64(2*1024*1024))
}
if cfg.Server.AccessLog.MaxBackups != 7 {
t.Fatalf("AccessLog.MaxBackups = %d, want 7", cfg.Server.AccessLog.MaxBackups)
}
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 len(cfg.Storage.EncryptionKeyBytes) != 32 {
t.Fatalf("EncryptionKeyBytes length = %d, want 32", len(cfg.Storage.EncryptionKeyBytes))
}
if _, err := os.Stat(filepath.Join(dataDir, dataDirEncryptionKeyFilename)); err != nil {
t.Fatalf("expected data-dir key file to exist: %v", err)
}
if !cfg.Security.TrustProxyHeaders {
t.Fatalf("TrustProxyHeaders = false, want true")
}
if cfg.Security.RateLimitUI.RequestsPerMinute != 12 || cfg.Security.RateLimitUI.Burst != 4 {
t.Fatalf("unexpected rate_limit_ui values: %+v", cfg.Security.RateLimitUI)
}
if cfg.Security.RateLimitAPIRead.RequestsPerMinute != 12 || cfg.Security.RateLimitAPIRead.Burst != 4 {
t.Fatalf("unexpected rate_limit_api_read values: %+v", cfg.Security.RateLimitAPIRead)
}
if cfg.Security.RateLimitAPIWrite.RequestsPerMinute != 12 || cfg.Security.RateLimitAPIWrite.Burst != 4 {
t.Fatalf("unexpected rate_limit_api_write values: %+v", cfg.Security.RateLimitAPIWrite)
}
if cfg.Security.HSTSEnabled {
t.Fatalf("HSTSEnabled = true, want false (from config)")
}
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: "negative read timeout",
mutate: func(cfg *Config) {
cfg.Server.ReadTimeout = "-1s"
},
wantErr: "server.read_timeout",
},
{
name: "invalid write timeout",
mutate: func(cfg *Config) {
cfg.Server.WriteTimeout = "bogus"
},
wantErr: "server.write_timeout",
},
{
name: "negative write timeout",
mutate: func(cfg *Config) {
cfg.Server.WriteTimeout = "-1s"
},
wantErr: "server.write_timeout",
},
{
name: "invalid idle timeout",
mutate: func(cfg *Config) {
cfg.Server.IdleTimeout = "bogus"
},
wantErr: "server.idle_timeout",
},
{
name: "invalid shutdown timeout",
mutate: func(cfg *Config) {
cfg.Server.ShutdownTimeout = "bogus"
},
wantErr: "server.shutdown_timeout",
},
{
name: "invalid max header bytes",
mutate: func(cfg *Config) {
cfg.Server.MaxHeaderBytes = 0
},
wantErr: "server.max_header_bytes",
},
{
name: "invalid access log max size",
mutate: func(cfg *Config) {
cfg.Server.AccessLog.MaxSize = "oops"
},
wantErr: "server.access_log.max_size",
},
{
name: "invalid access log max backups",
mutate: func(cfg *Config) {
cfg.Server.AccessLog.MaxBackups = 0
},
wantErr: "server.access_log.max_backups",
},
{
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 ui rate limit burst",
mutate: func(cfg *Config) {
cfg.Security.RateLimitUI.Burst = 0
},
wantErr: "security.rate_limit_ui.burst",
},
{
name: "invalid api read rate limit burst",
mutate: func(cfg *Config) {
cfg.Security.RateLimitAPIRead.Burst = 0
},
wantErr: "security.rate_limit_api_read.burst",
},
{
name: "invalid api write rate limit burst",
mutate: func(cfg *Config) {
cfg.Security.RateLimitAPIWrite.Burst = 0
},
wantErr: "security.rate_limit_api_write.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 ui rate limit rpm",
mutate: func(cfg *Config) {
cfg.Security.RateLimitUI.RequestsPerMinute = 0
},
wantErr: "security.rate_limit_ui.requests_per_minute",
},
{
name: "invalid api read rate limit rpm",
mutate: func(cfg *Config) {
cfg.Security.RateLimitAPIRead.RequestsPerMinute = 0
},
wantErr: "security.rate_limit_api_read.requests_per_minute",
},
{
name: "invalid api write rate limit rpm",
mutate: func(cfg *Config) {
cfg.Security.RateLimitAPIWrite.RequestsPerMinute = 0
},
wantErr: "security.rate_limit_api_write.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)
}
validConfigPath := filepath.Join(tmp, "valid.yaml")
validConfig := `
storage:
data_dir: "` + filepath.Join(tmp, "store") + `"
cleanup_interval: "10s"
`
if err := os.WriteFile(validConfigPath, []byte(validConfig), 0o644); err != nil {
t.Fatalf("WriteFile(valid config) error = %v", err)
}
_, err = Load(validConfigPath)
if err != nil {
t.Fatalf("Load() error = %v, want nil", err)
}
// First prepare creates the data dir (mkdir probe) so we can write the bad key file for test.
// (Load no longer has side effects.)
cfgForBad, err := Load(validConfigPath)
if err != nil {
t.Fatalf("Load(valid) error = %v", err)
}
if err := cfgForBad.PrepareDataDir(); err != nil {
t.Fatalf("PrepareDataDir (to create dir) error = %v", err)
}
keyPath := filepath.Join(tmp, "store", dataDirEncryptionKeyFilename)
if err := os.WriteFile(keyPath, []byte("not-base64"), 0o600); err != nil {
t.Fatalf("WriteFile(invalid key) error = %v", err)
}
// Re-prepare (or load+prepare) now hits the bad key decode inside ensure.
if err := cfgForBad.PrepareDataDir(); err == nil || !strings.Contains(err.Error(), "storage encryption key setup failed") {
t.Fatalf("PrepareDataDir after bad key error = %v, want setup failed containing 'storage encryption key setup failed'", err)
}
}
func TestLoadFailsWhenKeyMissingAndDataFilesExist(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
dataDir := filepath.Join(tmp, "store")
scratchesDir := filepath.Join(dataDir, "scratches")
if err := os.MkdirAll(scratchesDir, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(filepath.Join(scratchesDir, "existing-scratch"), []byte("payload"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
configPath := filepath.Join(tmp, "config.yaml")
configRaw := `
storage:
data_dir: "` + dataDir + `"
cleanup_interval: "10s"
`
if err := os.WriteFile(configPath, []byte(configRaw), 0o644); err != nil {
t.Fatalf("WriteFile(config) error = %v", err)
}
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v, want success (pure validate)", err)
}
err = cfg.PrepareDataDir()
if err == nil || !strings.Contains(err.Error(), "refusing to generate missing") {
t.Fatalf("PrepareDataDir() error = %v, want missing-key safety error containing 'refusing to generate missing'", err)
}
}
func TestLoadFromEnvParsesConfiguredFields(t *testing.T) {
dataDir := filepath.Join(t.TempDir(), "store")
t.Setenv("SCRATCHBOX_SERVER_LISTEN_ADDR", "127.0.0.1:19090")
t.Setenv("SCRATCHBOX_SERVER_READ_HEADER_TIMEOUT", "6s")
t.Setenv("SCRATCHBOX_SERVER_READ_TIMEOUT", "20s")
t.Setenv("SCRATCHBOX_SERVER_WRITE_TIMEOUT", "40s")
t.Setenv("SCRATCHBOX_SERVER_IDLE_TIMEOUT", "80s")
t.Setenv("SCRATCHBOX_SERVER_SHUTDOWN_TIMEOUT", "5s")
t.Setenv("SCRATCHBOX_SERVER_MAX_HEADER_BYTES", "2097152")
t.Setenv("SCRATCHBOX_SERVER_ACCESS_LOG_ENABLED", "false")
t.Setenv("SCRATCHBOX_SERVER_ACCESS_LOG_FILE_PATH", "/tmp/access.log")
t.Setenv("SCRATCHBOX_SERVER_ACCESS_LOG_MAX_SIZE", "4MiB")
t.Setenv("SCRATCHBOX_SERVER_ACCESS_LOG_MAX_BACKUPS", "8")
t.Setenv("SCRATCHBOX_LIMITS_MAX_UPLOAD_SIZE", "8MiB")
t.Setenv("SCRATCHBOX_LIMITS_DEFAULT_TTL", "30m")
t.Setenv("SCRATCHBOX_LIMITS_RAW_CACHE_MAX_SIZE", "64MiB")
t.Setenv("SCRATCHBOX_LIMITS_RAW_CACHE_MAX_ENTRIES", "16")
t.Setenv("SCRATCHBOX_STORAGE_DATA_DIR", dataDir)
t.Setenv("SCRATCHBOX_STORAGE_CLEANUP_INTERVAL", "15s")
t.Setenv("SCRATCHBOX_SECURITY_ALLOWED_IPS", "127.0.0.1,10.0.0.0/8")
t.Setenv("SCRATCHBOX_SECURITY_TRUST_PROXY_HEADERS", "true")
t.Setenv("SCRATCHBOX_SECURITY_TRUSTED_PROXY_IPS", "10.0.0.0/8")
t.Setenv("SCRATCHBOX_SECURITY_RATE_LIMIT_UI_ENABLED", "true")
t.Setenv("SCRATCHBOX_SECURITY_RATE_LIMIT_UI_REQUESTS_PER_MINUTE", "50")
t.Setenv("SCRATCHBOX_SECURITY_RATE_LIMIT_UI_BURST", "20")
t.Setenv("SCRATCHBOX_SECURITY_RATE_LIMIT_API_READ_ENABLED", "true")
t.Setenv("SCRATCHBOX_SECURITY_RATE_LIMIT_API_READ_REQUESTS_PER_MINUTE", "60")
t.Setenv("SCRATCHBOX_SECURITY_RATE_LIMIT_API_READ_BURST", "30")
t.Setenv("SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_ENABLED", "true")
t.Setenv("SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_REQUESTS_PER_MINUTE", "40")
t.Setenv("SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_BURST", "12")
t.Setenv("SCRATCHBOX_SECURITY_HSTS_ENABLED", "false")
cfg, err := LoadFromEnv()
if err != nil {
t.Fatalf("LoadFromEnv() error = %v", err)
}
if err := cfg.PrepareDataDir(); err != nil {
t.Fatalf("PrepareDataDir() error = %v", err)
}
if cfg.Server.ListenAddr != "127.0.0.1:19090" {
t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, "127.0.0.1:19090")
}
if cfg.Server.ReadHeaderTimeoutDur != 6*time.Second {
t.Fatalf("ReadHeaderTimeoutDur = %s, want 6s", cfg.Server.ReadHeaderTimeoutDur)
}
if cfg.Server.ReadTimeoutDur != 20*time.Second {
t.Fatalf("ReadTimeoutDur = %s, want 20s", cfg.Server.ReadTimeoutDur)
}
if cfg.Server.WriteTimeoutDur != 40*time.Second {
t.Fatalf("WriteTimeoutDur = %s, want 40s", cfg.Server.WriteTimeoutDur)
}
if cfg.Server.IdleTimeoutDur != 80*time.Second {
t.Fatalf("IdleTimeoutDur = %s, want 80s", cfg.Server.IdleTimeoutDur)
}
if cfg.Server.ShutdownTimeoutDur != 5*time.Second {
t.Fatalf("ShutdownTimeoutDur = %s, want 5s", cfg.Server.ShutdownTimeoutDur)
}
if cfg.Server.MaxHeaderBytes != 2*1024*1024 {
t.Fatalf("MaxHeaderBytes = %d, want %d", cfg.Server.MaxHeaderBytes, 2*1024*1024)
}
if cfg.Server.AccessLog.Enabled {
t.Fatalf("AccessLog.Enabled = %v, want false", cfg.Server.AccessLog.Enabled)
}
if cfg.Limits.MaxUploadSizeBytes != 8*1024*1024 {
t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(8*1024*1024))
}
if cfg.Limits.DefaultTTLDuration != 30*time.Minute {
t.Fatalf("DefaultTTLDuration = %s, want 30m", cfg.Limits.DefaultTTLDuration)
}
if cfg.Limits.RawCacheMaxBytes != 64*1024*1024 {
t.Fatalf("RawCacheMaxBytes = %d, want %d", cfg.Limits.RawCacheMaxBytes, int64(64*1024*1024))
}
if cfg.Limits.RawCacheMaxEntries != 16 {
t.Fatalf("RawCacheMaxEntries = %d, want 16", cfg.Limits.RawCacheMaxEntries)
}
if cfg.Storage.DataDir != dataDir {
t.Fatalf("DataDir = %q, want %q", cfg.Storage.DataDir, dataDir)
}
if cfg.Storage.CleanupIntervalParsed != 15*time.Second {
t.Fatalf("CleanupIntervalParsed = %s, want 15s", cfg.Storage.CleanupIntervalParsed)
}
if len(cfg.Storage.EncryptionKeyBytes) != 32 {
t.Fatalf("EncryptionKeyBytes length = %d, want 32", len(cfg.Storage.EncryptionKeyBytes))
}
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))
}
if !cfg.Security.TrustProxyHeaders {
t.Fatalf("TrustProxyHeaders = false, want true")
}
if cfg.Security.RateLimitUI.RequestsPerMinute != 50 || cfg.Security.RateLimitUI.Burst != 20 {
t.Fatalf("unexpected rate_limit_ui values: %+v", cfg.Security.RateLimitUI)
}
if cfg.Security.RateLimitAPIRead.RequestsPerMinute != 60 || cfg.Security.RateLimitAPIRead.Burst != 30 {
t.Fatalf("unexpected rate_limit_api_read values: %+v", cfg.Security.RateLimitAPIRead)
}
if cfg.Security.RateLimitAPIWrite.RequestsPerMinute != 40 || cfg.Security.RateLimitAPIWrite.Burst != 12 {
t.Fatalf("unexpected rate_limit_api_write values: %+v", cfg.Security.RateLimitAPIWrite)
}
if cfg.Security.HSTSEnabled {
t.Fatalf("HSTSEnabled from env = true, want false")
}
}
func TestLoadFromEnvRejectsInvalidBool(t *testing.T) {
t.Setenv("SCRATCHBOX_STORAGE_DATA_DIR", filepath.Join(t.TempDir(), "store"))
t.Setenv("SCRATCHBOX_SERVER_ACCESS_LOG_ENABLED", "not-bool")
_, err := LoadFromEnv()
if err == nil || !strings.Contains(err.Error(), "SCRATCHBOX_SERVER_ACCESS_LOG_ENABLED invalid bool") {
t.Fatalf("LoadFromEnv() error = %v, want invalid bool env 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"},
{in: "100000000000000000GiB", wantErr: "size too large"},
}
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)
}
})
}
}
func TestGenerateEncryptionKey(t *testing.T) {
t.Parallel()
k1, err := GenerateEncryptionKey()
if err != nil {
t.Fatalf("GenerateEncryptionKey() error = %v", err)
}
k2, err := GenerateEncryptionKey()
if err != nil {
t.Fatalf("GenerateEncryptionKey() second error = %v", err)
}
if k1 == k2 {
t.Fatalf("GenerateEncryptionKey() produced duplicate keys")
}
b1, err := base64.StdEncoding.DecodeString(k1)
if err != nil {
t.Fatalf("DecodeString(k1) error = %v", err)
}
if len(b1) != 32 {
t.Fatalf("decoded key length = %d, want 32", len(b1))
}
}
// TestEnvDocumentationIsUpToDate ensures .env.example and the env var
// documentation + example in README.md stay in sync with the env vars
// actually supported by the code (envSuffixes in config.go).
// See the comment on envSuffixes for the list of places that must point back to it.
//
// This is the guard against the recurring "example env and readme have
// become out of sync" problem. When you add a new field that is loaded via
// applyEnvOverrides, you must:
// - add its suffix to envSuffixes
// - add a line (with example value + comment) to .env.example
// - add it to the list and the ```dotenv example in README.md
//
// The test will fail loudly until the docs are updated.
func TestEnvDocumentationIsUpToDate(t *testing.T) {
t.Parallel()
// Check .env.example (at repo root). Note: go test for this package
// runs with cwd set to the package directory (internal/config), so we
// need to go up two levels.
envExample, err := os.ReadFile("../../.env.example")
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}
for _, suffix := range envSuffixes {
name := "SCRATCHBOX_" + suffix
if !strings.Contains(string(envExample), name) {
t.Errorf(".env.example is missing %s (add it with a default/example value and a comment matching the style of the others)", name)
}
}
// Check README.md env documentation (the supported list and the example block)
readme, err := os.ReadFile("../../README.md")
if err != nil {
t.Fatalf("failed to read README.md: %v", err)
}
for _, suffix := range envSuffixes {
name := "SCRATCHBOX_" + suffix
if !strings.Contains(string(readme), name) {
t.Errorf("README.md env docs (supported list or example .env block) are missing %s; update both the bullet list and the ```dotenv block", name)
}
}
}