package config import ( "os" "path/filepath" "testing" ) func TestLoadReadsCORSAllowedOrigins(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yaml") content := []byte(`server: port: 8083 cors: allowedOrigins: - https://console.example.com - http://localhost:3000 `) if err := os.WriteFile(path, content, 0600); err != nil { t.Fatalf("WriteFile() error = %v", err) } cfg, err := Load(path) if err != nil { t.Fatalf("Load() error = %v", err) } got := cfg.Server.CORS.AllowedOrigins want := []string{"https://console.example.com", "http://localhost:3000"} if len(got) != len(want) { t.Fatalf("len(AllowedOrigins)=%d want %d", len(got), len(want)) } for i := range want { if got[i] != want[i] { t.Fatalf("AllowedOrigins[%d]=%q want %q", i, got[i], want[i]) } } } func TestLoadOverridesValuesFromEnvironment(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yaml") content := []byte(`server: port: 8083 db: type: sqlite dsn: file:./data/app.db?_busy_timeout=5000 storage: local: baseUrl: /storage mcp: servers: system: endpoint: http://127.0.0.1:8083/api/mcp `) if err := os.WriteFile(path, content, 0600); err != nil { t.Fatalf("WriteFile() error = %v", err) } t.Setenv("AGENT_DESK_SERVER_PORT", "8090") t.Setenv("AGENT_DESK_DB_DSN", "mysql-dsn") t.Setenv("AGENT_DESK_STORAGE_LOCAL_BASEURL", "/files") t.Setenv("AGENT_DESK_MCP_SERVERS_SYSTEM_ENDPOINT", "http://127.0.0.1:8090/api/mcp") cfg, err := Load(path) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Server.Port != 8090 { t.Fatalf("Server.Port=%d want 8090", cfg.Server.Port) } if cfg.DB.Type != "sqlite" { t.Fatalf("DB.Type=%q want sqlite", cfg.DB.Type) } if cfg.DB.DSN != "mysql-dsn" { t.Fatalf("DB.DSN=%q want mysql-dsn", cfg.DB.DSN) } if cfg.Storage.Local.BaseURL != "/files" { t.Fatalf("Storage.Local.BaseURL=%q want /files", cfg.Storage.Local.BaseURL) } if cfg.MCP.Servers["system"].Endpoint != "http://127.0.0.1:8090/api/mcp" { t.Fatalf("MCP system endpoint=%q", cfg.MCP.Servers["system"].Endpoint) } } func TestLoadFromDotEnvAndStandardEnvAliases(t *testing.T) { tempDir := t.TempDir() envPath := filepath.Join(tempDir, ".env") envContent := []byte(`PORT=9090 COMPANY_NAME=CustomDesk COMPANY_LOGO_URL=/custom-logo.svg DATABASE_URL=postgres://user:pass@localhost:5432/mydb?sslmode=disable PASSWORD_LOGIN_ENABLED=false JWT_SECRET=super-secret-key-12345 QDRANT_HOST=10.0.0.5 QDRANT_PORT=6334 OIDC_ENABLED=true OIDC_ISSUER=https://auth.example.com OIDC_CLIENT_ID=client-123 OIDC_CLIENT_SECRET=secret-456 OIDC_REDIRECT_URL=https://desk.example.com/api/auth/oidc_callback ORG_SYNC_SECRET=webhook-secret-789 `) if err := os.WriteFile(envPath, envContent, 0600); err != nil { t.Fatalf("WriteFile() error = %v", err) } t.Setenv("ENV_FILE", envPath) cfg, err := Load("") if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Server.Port != 9090 { t.Fatalf("Server.Port=%d want 9090", cfg.Server.Port) } if cfg.Server.CompanyName != "CustomDesk" { t.Fatalf("Server.CompanyName=%q want CustomDesk", cfg.Server.CompanyName) } if cfg.Server.CompanyLogoURL != "/custom-logo.svg" { t.Fatalf("Server.CompanyLogoURL=%q want /custom-logo.svg", cfg.Server.CompanyLogoURL) } if cfg.DB.Type != "postgres" { t.Fatalf("DB.Type=%q want postgres", cfg.DB.Type) } if cfg.DB.DSN != "postgres://user:pass@localhost:5432/mydb?sslmode=disable" { t.Fatalf("DB.DSN=%q", cfg.DB.DSN) } if cfg.Auth.IsPasswordLoginEnabled() { t.Fatalf("expected PasswordLoginEnabled to be false") } if cfg.CustomerSession.Secret != "super-secret-key-12345" { t.Fatalf("CustomerSession.Secret=%q", cfg.CustomerSession.Secret) } if cfg.VectorDB.Qdrant.Host != "10.0.0.5" { t.Fatalf("Qdrant.Host=%q", cfg.VectorDB.Qdrant.Host) } if cfg.VectorDB.Qdrant.GrpcPort != 6334 { t.Fatalf("Qdrant.GrpcPort=%d", cfg.VectorDB.Qdrant.GrpcPort) } if !cfg.OIDC.Enabled { t.Fatalf("expected OIDC.Enabled=true") } if cfg.OIDC.Issuer != "https://auth.example.com" { t.Fatalf("OIDC.Issuer=%q", cfg.OIDC.Issuer) } if cfg.OIDC.ClientID != "client-123" { t.Fatalf("OIDC.ClientID=%q", cfg.OIDC.ClientID) } if cfg.OIDC.ClientSecret != "secret-456" { t.Fatalf("OIDC.ClientSecret=%q", cfg.OIDC.ClientSecret) } if cfg.OIDC.RedirectURL != "https://desk.example.com/api/auth/oidc_callback" { t.Fatalf("OIDC.RedirectURL=%q", cfg.OIDC.RedirectURL) } if cfg.Webhook.OrgSyncSecret != "webhook-secret-789" { t.Fatalf("Webhook.OrgSyncSecret=%q", cfg.Webhook.OrgSyncSecret) } } func TestAuthConfigMaxFailedAttemptsPerIPOrDefault(t *testing.T) { cases := []struct { name string cfg AuthConfig want int }{ {"explicit value wins", AuthConfig{MaxFailedAttempts: 5, MaxFailedAttemptsPerIP: 30}, 30}, {"unset derives four times the per-account limit", AuthConfig{MaxFailedAttempts: 5}, 20}, {"disabled per-account limit disables both", AuthConfig{MaxFailedAttempts: 0}, 0}, {"negative per-account limit disables both", AuthConfig{MaxFailedAttempts: -1}, 0}, } for _, tc := range cases { if got := tc.cfg.MaxFailedAttemptsPerIPOrDefault(); got != tc.want { t.Errorf("%s: MaxFailedAttemptsPerIPOrDefault() = %d want %d", tc.name, got, tc.want) } } } func TestServerConfigTrustedProxiesOrDefault(t *testing.T) { // An unset list must not fall through to Gin's own default of 0.0.0.0/0 and // ::/0, which trusts every peer and makes X-Forwarded-For authoritative. got := ServerConfig{}.TrustedProxiesOrDefault() for _, want := range []string{"127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1/128", "fc00::/7", "fe80::/10"} { found := false for _, entry := range got { if entry == want { found = true } } if !found { t.Errorf("TrustedProxiesOrDefault() = %v, missing %s", got, want) } } for _, entry := range got { if entry == "0.0.0.0/0" || entry == "::/0" { t.Errorf("TrustedProxiesOrDefault() includes %s, which trusts every peer", entry) } } // Blank entries from a comma-separated environment variable must not become // empty CIDRs, which Gin would reject at startup. got = ServerConfig{TrustedProxies: []string{" 10.1.0.0/16 ", "", " "}}.TrustedProxiesOrDefault() if len(got) != 1 || got[0] != "10.1.0.0/16" { t.Errorf("TrustedProxiesOrDefault() = %v want [10.1.0.0/16]", got) } } func TestServerConfigTrustedPlatformHeader(t *testing.T) { cases := []struct { platform string want string }{ {"", ""}, {"cloudflare", "CF-Connecting-IP"}, {"Cloudflare", "CF-Connecting-IP"}, {" CF ", "CF-Connecting-IP"}, {"fly.io", "Fly-Client-IP"}, {"google-app-engine", "X-Appengine-Remote-Addr"}, // Anything unrecognised is a literal header name, which is what Gin's // TrustedPlatform field expects. {"X-CDN-IP", "X-CDN-IP"}, } for _, tc := range cases { if got := (ServerConfig{TrustedPlatform: tc.platform}).TrustedPlatformHeader(); got != tc.want { t.Errorf("TrustedPlatformHeader(%q) = %q want %q", tc.platform, got, tc.want) } } } // TestLoadReadsTrustedProxySettings covers the environment spelling, including // the comma-separated list. Gin rejects an unparseable CIDR at startup, so a // value that arrives as one string instead of a slice would take the process // down rather than degrade quietly. func TestLoadReadsTrustedProxySettings(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yaml") if err := os.WriteFile(path, []byte("server:\n port: 8083\n"), 0600); err != nil { t.Fatalf("WriteFile() error = %v", err) } t.Setenv("ENV_FILE", os.DevNull) t.Setenv("AGENT_DESK_ENV_FILE", os.DevNull) t.Setenv("TRUSTED_PROXIES", "10.0.0.0/8,172.16.0.0/12") t.Setenv("TRUSTED_PLATFORM", "cloudflare") cfg, err := Load(path) if err != nil { t.Fatalf("Load() error = %v", err) } want := []string{"10.0.0.0/8", "172.16.0.0/12"} got := cfg.Server.TrustedProxies if len(got) != len(want) { t.Fatalf("TrustedProxies = %v want %v", got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("TrustedProxies[%d] = %q want %q", i, got[i], want[i]) } } if cfg.Server.TrustedPlatformHeader() != "CF-Connecting-IP" { t.Errorf("TrustedPlatformHeader() = %q want CF-Connecting-IP", cfg.Server.TrustedPlatformHeader()) } } func TestRateLimitConfigDefaultsToEnabledWithAMinuteWindow(t *testing.T) { var cfg RateLimitConfig if !cfg.IsEnabled() { t.Error("rate limiting must default to enabled; a zero-value config should not silently turn protection off") } if got := cfg.WindowSecondsOrDefault(); got != 60 { t.Errorf("WindowSecondsOrDefault() = %d want 60", got) } off := false cfg = RateLimitConfig{Enabled: &off, WindowSeconds: -5} if cfg.IsEnabled() { t.Error("Enabled=false must disable the limits") } if got := cfg.WindowSecondsOrDefault(); got != 60 { t.Errorf("a non-positive window must fall back to 60, got %d", got) } on := true cfg = RateLimitConfig{Enabled: &on, WindowSeconds: 30} if !cfg.IsEnabled() { t.Error("Enabled=true must enable the limits") } if got := cfg.WindowSecondsOrDefault(); got != 30 { t.Errorf("WindowSecondsOrDefault() = %d want 30", got) } } func TestLoadReadsRateLimitSettings(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yaml") if err := os.WriteFile(path, []byte("server:\n port: 8083\n"), 0600); err != nil { t.Fatalf("WriteFile() error = %v", err) } t.Setenv("ENV_FILE", os.DevNull) t.Setenv("AGENT_DESK_ENV_FILE", os.DevNull) t.Setenv("RATE_LIMIT_ENABLED", "false") t.Setenv("RATE_LIMIT_WINDOW_SECONDS", "30") cfg, err := Load(path) if err != nil { t.Fatalf("Load() error = %v", err) } if cfg.Server.RateLimit.IsEnabled() { t.Error("RATE_LIMIT_ENABLED=false did not disable the limits") } if got := cfg.Server.RateLimit.WindowSecondsOrDefault(); got != 30 { t.Errorf("WindowSecondsOrDefault() = %d want 30", got) } }