package config import ( "fmt" "os" "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestValidate_ValidConfig(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"}, Database: DatabaseConfig{ DSN: "postgres://gochat:secret@localhost:5432/gochat_db?sslmode=disable", }, Redis: RedisConfig{ DSN: "redis://localhost:6379", }, JWT: JWTConfig{ Secret: "test-secret-key-min-32-chars!!", ExpiryHours: 24, RefreshExpiryHours: 168, }, Log: LogConfig{Level: "info", Format: "json"}, Worker: WorkerConfig{Concurrency: 4, BlockTimeoutS: 5, SweepIntervalS: 30}, OAuth: OAuthConfig{}, Search: SearchConfig{Engine: "meilisearch", Host: "http://localhost:7700", IndexPrefix: "gochat_", TimeoutSeconds: 5}, } err := Validate(cfg) assert.NoError(t, err) } func TestValidate_InvalidPort(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 0, Mode: "debug"}, Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"}, Redis: RedisConfig{DSN: "redis://localhost:6379"}, JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"}, } err := Validate(cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid server port") } func TestValidate_InvalidMode(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "invalid"}, Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"}, Redis: RedisConfig{DSN: "redis://localhost:6379"}, JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"}, } err := Validate(cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid server mode") } func TestValidate_MissingDBDSN(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"}, Database: DatabaseConfig{}, Redis: RedisConfig{DSN: "redis://localhost:6379"}, JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"}, } err := Validate(cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "database DSN is required") } func TestValidate_InvalidDBDSN(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"}, Database: DatabaseConfig{DSN: "mysql://user@localhost/db"}, Redis: RedisConfig{DSN: "redis://localhost:6379"}, JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"}, } err := Validate(cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid database DSN") } func TestValidate_MissingRedisDSN(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"}, Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"}, JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"}, } err := Validate(cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "redis DSN is required") } func TestValidate_InvalidRedisDSN(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"}, Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"}, Redis: RedisConfig{DSN: "not-a-valid-url://::"}, JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"}, } err := Validate(cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid redis DSN") } func TestValidate_JWTSecretInProduction(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release"}, Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"}, Redis: RedisConfig{DSN: "redis://localhost:6379"}, JWT: JWTConfig{Secret: "change-me-in-production"}, } err := Validate(cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "JWT secret") } func TestValidate_ReleaseJWTSecurity(t *testing.T) { validSecret := "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE" base := func() *Config { return &Config{ Server: ServerConfig{Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}}, Database: DatabaseConfig{DSN: "postgres://user:database-secret@postgres:5432/db?sslmode=disable"}, Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"}, JWT: JWTConfig{Secret: validSecret}, Log: LogConfig{Level: "info"}, Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 1, SweepIntervalS: 1}, Search: SearchConfig{Engine: "meilisearch", Host: "http://localhost:7700", APIKey: "search-secret-123"}, } } assert.NoError(t, Validate(base())) short := base() short.JWT.Secret = "too-short" assert.ErrorContains(t, Validate(short), "at least 32 bytes") placeholder := base() placeholder.JWT.Secret = "gochat_dev_secret_change_in_production" assert.ErrorContains(t, Validate(placeholder), "placeholder") insecureHeaders := base() insecureHeaders.JWT.AllowInsecureHeaderAuth = true assert.ErrorContains(t, Validate(insecureHeaders), "header authentication") duplicate := base() duplicate.JWT.PreviousSecrets = []string{validSecret} assert.ErrorContains(t, Validate(duplicate), "must be unique") } func TestValidateRuntimeEnvironmentRequiresInjectedReleaseSecret(t *testing.T) { t.Setenv("GOCHAT_JWT_SECRET", "") t.Setenv("JWT_SECRET", "") cfg := &Config{Server: ServerConfig{Mode: "release"}} assert.ErrorContains(t, validateRuntimeEnvironment("prod", cfg), "requires GOCHAT_JWT_SECRET") t.Setenv("GOCHAT_JWT_SECRET", "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE") assert.NoError(t, validateRuntimeEnvironment("production", cfg)) } func TestParseJWTSecretList(t *testing.T) { assert.Equal(t, []string{"old-one", "old-two"}, parseJWTSecretList(" old-one, ,old-two ")) } func TestValidate_InvalidLogLevel(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"}, Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"}, Redis: RedisConfig{DSN: "redis://localhost:6379"}, JWT: JWTConfig{Secret: "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE"}, Log: LogConfig{Level: "invalid"}, } err := Validate(cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid log level") } func TestValidate_InvalidWorkerConcurrency(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"}, Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"}, Redis: RedisConfig{DSN: "redis://localhost:6379"}, JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"}, Log: LogConfig{Level: "info"}, Worker: WorkerConfig{Concurrency: 0}, } err := Validate(cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "worker concurrency") } func TestValidate_SearchMeilisearchRequiresValidHost(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"}, Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"}, Redis: RedisConfig{DSN: "redis://localhost:6379"}, JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"}, Log: LogConfig{Level: "info"}, Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30}, Search: SearchConfig{Engine: "meilisearch", Host: "not a url", TimeoutSeconds: 5}, } err := Validate(cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "invalid search.host") } func TestValidate_SearchDBFallbackAllowed(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "debug"}, Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"}, Redis: RedisConfig{DSN: "redis://localhost:6379"}, JWT: JWTConfig{Secret: "test-secret-key-min-32-chars!!"}, Log: LogConfig{Level: "info"}, Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30}, Search: SearchConfig{Engine: "db"}, } err := Validate(cfg) assert.NoError(t, err) } func TestValidate_SearchDBFallbackRejectedInRelease(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release"}, Database: DatabaseConfig{DSN: "postgres://user@localhost:5432/db?sslmode=disable"}, Redis: RedisConfig{DSN: "redis://localhost:6379"}, JWT: JWTConfig{Secret: "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE"}, Log: LogConfig{Level: "info"}, Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30}, Search: SearchConfig{Engine: "db"}, } err := Validate(cfg) assert.Error(t, err) assert.Contains(t, err.Error(), "release mode requires meilisearch") } func TestValidate_ReleaseRejectsPlaceholders(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}}, Database: DatabaseConfig{DSN: "postgres://gochat:CHANGE_ME@postgres:5432/gochat"}, Redis: RedisConfig{DSN: "redis://:secret@redis:6379"}, JWT: JWTConfig{Secret: "production-jwt-secret-at-least-32-characters"}, Log: LogConfig{Level: "info"}, Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30}, Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "search-secret", TimeoutSeconds: 5}, } err := Validate(cfg) assert.ErrorContains(t, err, "database password") } func TestValidate_ReleaseRejectsShortSearchKey(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}}, Database: DatabaseConfig{DSN: "postgres://gochat:database-secret@postgres:5432/gochat?sslmode=disable"}, Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"}, JWT: JWTConfig{Secret: "production-jwt-secret-at-least-32-characters"}, Log: LogConfig{Level: "info"}, Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30}, Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "123456789012345", TimeoutSeconds: 5}, } assert.ErrorContains(t, Validate(cfg), "search API key must be at least 16 bytes") } func TestValidate_ReleaseDatabaseTLS(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.example.test"}}}, Redis: RedisConfig{DSN: "redis://:redis-secret@redis:6379"}, JWT: JWTConfig{Secret: "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE"}, Log: LogConfig{Level: "info"}, Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30}, Search: SearchConfig{Engine: "meilisearch", Host: "http://meilisearch:7700", APIKey: "search-secret-123", TimeoutSeconds: 5}, } for _, tt := range []struct { name string dsn string wantErr bool }{ {"external disable", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=disable", true}, {"external missing sslmode", "postgres://gochat:database-secret@db.example.test:5432/gochat", true}, {"external require", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=require", false}, {"built-in compose disable", "postgres://gochat:database-secret@postgres:5432/gochat?sslmode=disable", false}, } { t.Run(tt.name, func(t *testing.T) { cfg.Database.DSN = tt.dsn if tt.wantErr { assert.ErrorContains(t, Validate(cfg), "production database DSN must use sslmode") } else { assert.NoError(t, Validate(cfg)) } }) } } func TestLoadWithEnv_ProductionRequiresOverlay(t *testing.T) { tmpDir := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(tmpDir, "configs"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "configs", "config.yaml"), []byte("server:\n mode: debug\n"), 0o644)) t.Chdir(tmpDir) _, err := LoadWithEnv("production") assert.ErrorContains(t, err, "production config overlay not found") } func TestLoadWithEnv_ProductionOverlay(t *testing.T) { t.Setenv("GOCHAT_DATABASE_DSN", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=require") t.Setenv("GOCHAT_REDIS_DSN", "redis://:redis-secret@redis:6379") t.Setenv("GOCHAT_JWT_SECRET", "production-jwt-secret-at-least-32-characters") t.Setenv("GOCHAT_SEARCH_API_KEY", "search-secret-123") t.Setenv("GOCHAT_SERVER_CORS_ALLOWED_ORIGINS", "https://chat.example.test") t.Chdir("../..") cfg, err := LoadWithEnv("production") require.NoError(t, err) assert.Equal(t, "release", cfg.Server.Mode) assert.Equal(t, []string{"https://chat.example.test"}, cfg.Server.CORS.AllowedOrigins) assert.NoError(t, Validate(cfg)) } func TestDatabaseConfig_MigrateDSN(t *testing.T) { cfg := DatabaseConfig{ DSN: "postgres://gochat:secret@localhost:5432/gochat_db?sslmode=disable", } dsn := cfg.MigrateDSN() assert.Equal(t, "postgres://gochat:secret@localhost:5432/gochat_db?sslmode=disable", dsn) } func TestJWTConfig_ExpiryDuration(t *testing.T) { cfg := JWTConfig{ExpiryHours: 24} dur := cfg.ExpiryDuration() assert.Equal(t, 24*time.Hour, dur) } func TestServerConfig_Address(t *testing.T) { cfg := ServerConfig{Host: "0.0.0.0", Port: 3000} addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) assert.Equal(t, "0.0.0.0:3000", addr) } func TestLoadWithEnv_CopilotRuntimeContract(t *testing.T) { t.Chdir("../..") t.Setenv("GOCHAT_COPILOT_PROVIDER_CONFIG", `{"chat":{"provider":"openai_compatible"}}`) t.Setenv("GOCHAT_COPILOT_CHAT_API_KEY", "runtime-chat-key") t.Setenv("GOCHAT_COPILOT_EMBEDDING_API_KEY", "runtime-embedding-key") cfg, err := LoadWithEnv("default") require.NoError(t, err) assert.Equal(t, `{"chat":{"provider":"openai_compatible"}}`, cfg.Copilot.ProviderConfig) assert.Equal(t, "runtime-chat-key", cfg.Copilot.ChatAPIKey) assert.Equal(t, "runtime-embedding-key", cfg.Copilot.EmbeddingAPIKey) }