package config import ( "bufio" "fmt" "os" "path/filepath" "strings" "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.acme.test"}}}, Database: DatabaseConfig{DSN: "postgres://user:database-secret@db.acme.test:5432/db?sslmode=verify-full"}, Redis: RedisConfig{DSN: "rediss://:redis-secret@redis.acme.test:6379"}, JWT: JWTConfig{Secret: validSecret, AccessExpiryMinutes: 15}, Encryption: EncryptionConfig{Enabled: true, CurrentKeyVersion: 1, AESKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}, Log: LogConfig{Level: "info"}, Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 1, SweepIntervalS: 1}, Search: SearchConfig{Engine: "meilisearch", Host: "https://search.acme.test", 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.acme.test"}}}, Database: DatabaseConfig{DSN: "postgres://gochat:database-secret@db.acme.test:5432/gochat?sslmode=verify-full"}, Redis: RedisConfig{DSN: "rediss://:redis-secret@redis.acme.test:6379"}, JWT: JWTConfig{Secret: "production-jwt-secret-at-least-32-characters", AccessExpiryMinutes: 15}, Encryption: EncryptionConfig{Enabled: true, CurrentKeyVersion: 1, AESKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}, Log: LogConfig{Level: "info"}, Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30}, Search: SearchConfig{Engine: "meilisearch", Host: "https://search.acme.test", APIKey: "123456789012345", TimeoutSeconds: 5}, } assert.ErrorContains(t, Validate(cfg), "search API key must be at least 16 bytes") } func TestValidate_ReleaseDatabaseTransport(t *testing.T) { cfg := &Config{ Server: ServerConfig{Host: "localhost", Port: 8080, Mode: "release", CORS: CORSConfig{AllowedOrigins: []string{"https://chat.acme.test"}}}, Redis: RedisConfig{DSN: "rediss://:redis-secret@redis.acme.test:6379"}, JWT: JWTConfig{Secret: "6vG3uP9qL2mR8xK5nD7sF4hJ1cB0wZyE", AccessExpiryMinutes: 15}, Encryption: EncryptionConfig{Enabled: true, CurrentKeyVersion: 1, AESKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}, Log: LogConfig{Level: "info"}, Worker: WorkerConfig{Concurrency: 1, BlockTimeoutS: 5, SweepIntervalS: 30}, Search: SearchConfig{Engine: "meilisearch", Host: "https://search.acme.test", 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", false}, {"external missing sslmode", "postgres://gochat:database-secret@db.example.test:5432/gochat", true}, {"external duplicate downgrade", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=disable&sslmode=verify-full", true}, {"external duplicate allowed", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=verify-full&sslmode=verify-full", true}, {"external non-fixed certificate path", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=verify-full&sslrootcert=/tmp/ca.crt&sslcert=/run/secrets/external-db-client.crt&sslkey=/run/secrets/external-db-client.key", true}, {"external require", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=require", true}, {"external verify ca", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=verify-ca", false}, {"external verify full", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=verify-full", false}, {"external fixed certificate paths", "postgres://gochat:database-secret@db.example.test:5432/gochat?sslmode=verify-full&sslrootcert=/run/secrets/external-db-ca.crt&sslcert=/run/secrets/external-db-client.crt&sslkey=/run/secrets/external-db-client.key", false}, } { t.Run(tt.name, func(t *testing.T) { cfg.Database.DSN = tt.dsn if tt.wantErr { assert.Error(t, Validate(cfg)) } else { assert.NoError(t, Validate(cfg)) } }) } } func TestValidateProductionDatabaseDSN_RejectsHostMatrix(t *testing.T) { file, err := os.Open("../../../deploy/docker/database_host_rejection_cases.txt") require.NoError(t, err) t.Cleanup(func() { require.NoError(t, file.Close()) }) scanner := bufio.NewScanner(file) for scanner.Scan() { name, dsn, ok := strings.Cut(scanner.Text(), "|") require.True(t, ok) t.Run(name, func(t *testing.T) { assert.ErrorContains(t, ValidateProductionDatabaseDSN(dsn), "must use an external PostgreSQL host") }) } require.NoError(t, scanner.Err()) } func TestProductionDatabaseTransportRunbookContract(t *testing.T) { runbook, err := os.ReadFile("../../../docs/ops/02-production-operations.md") require.NoError(t, err) runbookText := string(runbook) assert.Contains(t, runbookText, "use `disable` only on a trusted private network") assert.Contains(t, runbookText, "`verify-ca`/`verify-full`") assert.NotContains(t, runbookText, "sslmode=require") } 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_CORSYAMLRoundTrip(t *testing.T) { tmpDir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte(`server: cors: allowed_origins: ["https://chat.acme.test"] allow_credentials: true `), 0o644)) t.Chdir(tmpDir) cfg, err := LoadWithEnv("default") require.NoError(t, err) require.Equal(t, []string{"https://chat.acme.test"}, cfg.Server.CORS.AllowedOrigins) require.True(t, cfg.Server.CORS.AllowCredentials) } func TestLoadWithEnv_ProductionOverlay(t *testing.T) { t.Setenv("GOCHAT_DATABASE_DSN", "postgres://gochat:database-secret@db.acme.test:5432/gochat?sslmode=verify-full") t.Setenv("GOCHAT_REDIS_DSN", "rediss://:redis-secret@redis.acme.test: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_ENCRYPTION_ENABLED", "true") t.Setenv("GOCHAT_ENCRYPTION_CURRENT_KEY_VERSION", "1") t.Setenv("GOCHAT_ENCRYPTION_AES_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") t.Chdir("../..") cfg, err := LoadWithEnv("production") require.NoError(t, err) assert.Equal(t, "release", cfg.Server.Mode) 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) }