Files
agent-desk-ng/internal/pkg/config/config_test.go

314 lines
9.7 KiB
Go

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)
}
}