package config import ( "os" "path/filepath" "strings" "testing" ) func TestLoadJSONConfig(t *testing.T) { dir := t.TempDir() content := `{ "listen": ":9090", "admin_password": "secret", "client_signature_key": "client-key", "package_encryption_key": "package-key", "timestamp_window_seconds": 90, "max_request_bytes": 123, "max_package_bytes": 456, "storage_dir": "./data", "database_path": "./data/feedback.sqlite", "mail": { "host": "smtp.example.com", "port": 587, "secure": "starttls", "username": "u", "password": "p", "from_address": "from@example.com", "from_name": "Feedback", "developer_address": "dev@example.com", "timeout_seconds": 7 } }` if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(content), 0o600); err != nil { t.Fatal(err) } cfg, err := Load(dir) if err != nil { t.Fatal(err) } if cfg.Listen != ":9090" || cfg.AdminPassword != "secret" || cfg.ClientSignatureKey != "client-key" { t.Fatalf("unexpected top-level config: %+v", cfg) } if cfg.StorageDir != filepath.Join(dir, "data") { t.Fatalf("storage dir was not normalized: %q", cfg.StorageDir) } if cfg.Mail.Host != "smtp.example.com" || cfg.Mail.Port != 587 || cfg.Mail.Secure != "starttls" { t.Fatalf("unexpected mail config: %+v", cfg.Mail) } } func TestConfigTxtTakesPriorityOverJSON(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"admin_password":"json-secret"}`), 0o600); err != nil { t.Fatal(err) } content := ` ':9191', 'admin_password' => $adminPassword, 'storage_dir' => __DIR__ . '/storage', 'database_path' => __DIR__ . '/storage/feedback.sqlite', ];` if err := os.WriteFile(filepath.Join(dir, "config.txt"), []byte(content), 0o600); err != nil { t.Fatal(err) } cfg, err := Load(dir) if err != nil { t.Fatal(err) } if cfg.AdminPassword != "txt-secret" || cfg.Listen != ":9191" { t.Fatalf("config.txt should win over config.json, got password=%q listen=%q", cfg.AdminPassword, cfg.Listen) } } func TestLoadConfigTxtLegacyArrayConfig(t *testing.T) { dir := t.TempDir() content := ` ':7070', 'admin_password_hash' => '', 'admin_password' => $adminPassword, 'client_signature_key' => 'legacy-client', 'package_encryption_key' => 'legacy-package', 'timestamp_window_seconds' => 120, 'max_request_bytes' => 12 * 1024 * 1024, 'max_package_bytes' => 10 * 1024 * 1024, 'storage_dir' => __DIR__ . '/storage', 'database_path' => __DIR__ . '/storage/feedback.sqlite', 'mail' => [ 'host' => 'mail.example.com', 'port' => 465, 'secure' => 'ssl', 'username' => 'sender@example.com', 'password' => 'mail-secret', 'from_address' => 'sender@example.com', 'from_name' => 'YMhut Box Feedback', 'developer_address' => 'developer@example.com', 'timeout_seconds' => 20, ], ];` if err := os.WriteFile(filepath.Join(dir, "config.txt"), []byte(content), 0o600); err != nil { t.Fatal(err) } cfg, err := Load(dir) if err != nil { t.Fatal(err) } if cfg.Listen != ":7070" || cfg.AdminPassword != "legacy-secret" || cfg.ClientSignatureKey != "legacy-client" || cfg.PackageEncryptionKey != "legacy-package" { t.Fatalf("unexpected legacy config: %+v", cfg) } if cfg.MaxRequestBytes != 12*1024*1024 || cfg.MaxPackageBytes != 10*1024*1024 { t.Fatalf("legacy integer expressions were not evaluated: %+v", cfg) } if cfg.StorageDir != filepath.Join(dir, "storage") || cfg.DatabasePath != filepath.Join(dir, "storage", "feedback.sqlite") { t.Fatalf("legacy __DIR__ paths were not normalized: %q %q", cfg.StorageDir, cfg.DatabasePath) } if cfg.Mail.Password != "mail-secret" || cfg.Mail.DeveloperAddress != "developer@example.com" { t.Fatalf("unexpected legacy mail config: %+v", cfg.Mail) } } func TestLoadConfigTxtEnhancedSettings(t *testing.T) { dir := t.TempDir() content := ` ':7071', 'admin_password' => 'secret', 'storage_dir' => __DIR__ . '/storage', 'database_path' => __DIR__ . '/storage/feedback.sqlite', 'submission_per_minute' => 9, 'max_zip_files' => 12, 'max_decompressed_bytes' => 1024 * 1024, 'backup_dir' => __DIR__ . '/storage/backups', 'webhooks' => [ [ 'name' => 'ops', 'url' => 'https://example.com/hook', 'secret' => 'hook-secret', 'enabled' => true, 'events' => ['feedback.created', 'mail.failed'], 'timeout_seconds' => 3, 'max_retries' => 1, ], ], ];` if err := os.WriteFile(filepath.Join(dir, "config.txt"), []byte(content), 0o600); err != nil { t.Fatal(err) } cfg, err := Load(dir) if err != nil { t.Fatal(err) } if cfg.RateLimit.SubmissionPerMinute != 9 || cfg.UploadGuard.MaxZipFiles != 12 || cfg.UploadGuard.MaxDecompressedBytes != 1024*1024 { t.Fatalf("enhanced settings were not parsed: %+v", cfg) } if cfg.Backup.Dir != filepath.Join(dir, "storage", "backups") { t.Fatalf("backup dir was not normalized: %q", cfg.Backup.Dir) } if len(cfg.Webhooks) != 1 || cfg.Webhooks[0].Name != "ops" || cfg.Webhooks[0].Events[1] != "mail.failed" || cfg.Webhooks[0].MaxRetries != 1 { t.Fatalf("webhooks were not parsed: %+v", cfg.Webhooks) } } func TestLoadConfigTxtDatabaseSettings(t *testing.T) { dir := t.TempDir() content := ` __DIR__ . '/storage', 'database_path' => __DIR__ . '/storage/feedback.sqlite', 'database_provider' => 'postgres', 'database_host' => 'db.example.com', 'database_port' => 5433, 'database_name' => 'feedback', 'database_user' => 'feedback_user', 'database_password' => 'db-secret', 'database_ssl_mode' => 'require', 'database_failover_enabled' => true, 'database_sync_enabled' => true, 'database_sync_interval_seconds' => 60, 'database_sync_batch_size' => 25, ];` if err := os.WriteFile(filepath.Join(dir, "config.txt"), []byte(content), 0o600); err != nil { t.Fatal(err) } cfg, err := Load(dir) if err != nil { t.Fatal(err) } if cfg.Database.Provider != "postgres" || cfg.Database.Host != "db.example.com" || cfg.Database.Port != 5433 || cfg.Database.Password != "db-secret" { t.Fatalf("database config was not parsed: %+v", cfg.Database) } if cfg.Database.SQLitePath != filepath.Join(dir, "storage", "feedback.sqlite") || cfg.DatabasePath != cfg.Database.SQLitePath { t.Fatalf("sqlite path compatibility failed: %q %q", cfg.Database.SQLitePath, cfg.DatabasePath) } if !cfg.Database.FailoverEnabled || !cfg.Database.Sync.Enabled || cfg.Database.Sync.IntervalSeconds != 60 || cfg.Database.Sync.BatchSize != 25 { t.Fatalf("database sync settings were not parsed: %+v", cfg.Database) } } func TestSaveConfigCreatesReloadableConfigTxt(t *testing.T) { dir := t.TempDir() cfg := Default(dir) cfg.Listen = ":9099" cfg.Database.Provider = "mysql" cfg.Database.Host = "mysql.example.com" cfg.Database.Name = "feedback" cfg.Database.User = "feedback_user" cfg.Database.Password = "mysql-secret" cfg.Webhooks = []WebhookConfig{{Name: "ops", URL: "https://example.com/hook", Secret: "hook-secret", Enabled: true, Events: []string{"feedback.created"}, TimeoutSeconds: 4, MaxRetries: 2}} if err := Save(cfg); err != nil { t.Fatal(err) } saved, err := os.ReadFile(filepath.Join(dir, "config.txt")) if err != nil { t.Fatal(err) } text := string(saved) if strings.Contains(text, dir) { t.Fatalf("saved config should not contain deploy-root absolute paths: %s", text) } for _, expected := range []string{ "'storage_dir' => 'storage'", "'database_path' => 'storage/feedback.sqlite'", "'database_sqlite_path' => 'storage/feedback.sqlite'", "'backup_dir' => 'storage/backups'", } { if !strings.Contains(text, expected) { t.Fatalf("saved config is missing portable path %q: %s", expected, text) } } loaded, err := Load(dir) if err != nil { t.Fatal(err) } if loaded.Listen != ":9099" || loaded.Database.Provider != "mysql" || loaded.Database.Password != "mysql-secret" { t.Fatalf("saved config did not reload: %+v", loaded.Database) } if len(loaded.Webhooks) != 1 || loaded.Webhooks[0].Secret != "hook-secret" { t.Fatalf("saved webhooks did not reload: %+v", loaded.Webhooks) } }