package config import ( "os" "path/filepath" "strings" "testing" "time" ) // Проверка перечня доверенных адресов — единственная страховка от того, чтобы // сервис поднялся никого не узнающим. Узнать о такой поломке было бы неоткуда: // все адреса приложения просто отвечали бы отказом. func validAuthConfig() AuthConfig { return AuthConfig{TrustedProxies: []string{"172.16.0.0/12", "127.0.0.1"}} } func TestAuthConfigValidateAcceptsFilled(t *testing.T) { if err := validAuthConfig().Validate(); err != nil { t.Fatalf("заполненный перечень отвергнут: %v", err) } } // Пустой перечень значит «не верить никому»: сервис поднялся бы, никого не // узнавая, и молчать об этом старт не вправе. func TestAuthConfigValidateRejectsEmptyList(t *testing.T) { err := AuthConfig{}.Validate() if err == nil { t.Fatal("пустой перечень принят: сервис поднялся бы никого не узнающим") } if !strings.Contains(err.Error(), "trusted_proxies") { t.Fatalf("имя ключа не названо: %v", err) } } // Нечитаемая строка роняет старт: перечень с опечаткой проверяется только тем, // что кто-то не смог войти. func TestAuthConfigValidateRejectsMalformedEntry(t *testing.T) { for _, value := range []string{"", "not-an-address", "10.0.0.0/99", "10.0.0.256"} { t.Run(value, func(t *testing.T) { cfg := AuthConfig{TrustedProxies: []string{value}} err := cfg.Validate() if err == nil { t.Fatalf("строка %q принята как адрес", value) } if !strings.Contains(err.Error(), "trusted_proxies") { t.Fatalf("имя ключа не названо: %v", err) } }) } } // Одиночный адрес принимается наравне с подсетью и становится подсетью на один // адрес: писать разрядность руками значит её помнить, а перечень читает человек. func TestTrustedNetworksAcceptsBareAddress(t *testing.T) { networks, err := AuthConfig{TrustedProxies: []string{"10.1.2.3"}}.TrustedNetworks() if err != nil { t.Fatalf("одиночный адрес отвергнут: %v", err) } if len(networks) != 1 { t.Fatalf("подсетей %d, ожидалась одна", len(networks)) } if !networks[0].IsSingleIP() { t.Fatalf("одиночный адрес стал подсетью шире одного адреса: %s", networks[0]) } } func writeConfig(t *testing.T, body string) string { t.Helper() path := filepath.Join(t.TempDir(), "config.toml") if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatalf("не удалось записать файл настроек: %v", err) } return path } const validConfigBody = ` [storage] data_dir = "data" ` // Инвариант «секрет не покидает конфиг»: текст отказа разбора собирает чужая // библиотека из разбираемого куска файла, и оборванная строка ключа доступа // уехала бы в журнал вместе со значением. Отсюда собственное сообщение. func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) { const secret = "AQVNfake-secret-api-key-value" // Кавычка не закрыта: разбор оборвётся на значении. path := writeConfig(t, "[yandex]\nfolder_id = \"b1g\"\nspeech_kit_api_key = \""+secret+"\n") _, err := LoadConfig(path) if err == nil { t.Fatal("поломанный файл настроек принят") } message := err.Error() for _, part := range []string{secret, "AQVNfake", "secret-api-key-value"} { if strings.Contains(message, part) { t.Fatalf("значение ключа доступа уехало в отказ: %v", err) } } // Без места и ключа отказ нечинибелен: скрыть значение мало. if !strings.Contains(message, "строке 3") { t.Fatalf("номер строки не назван, чинить нечего: %v", err) } if !strings.Contains(message, "speech_kit_api_key") { t.Fatalf("ключ не назван, чинить нечего: %v", err) } } // Обратная сторона того же разреза: отказ несовпадения типов собран из имён // ключей и типов, значений в нём нет, и выбрасывать его текст незачем. func TestLoadConfigTypeMismatchKeepsDiagnostics(t *testing.T) { path := writeConfig(t, "[server]\nport = \"8080\"\n"+validConfigBody) _, err := LoadConfig(path) if err == nil { t.Fatal("строка вместо числа принята") } if !strings.Contains(err.Error(), "port") { t.Fatalf("имя ключа не названо, чинить нечего: %v", err) } } // Вторая ветка разреза: разбор оборвался до всякого ключа, и последнего ключа // нет вовсе. Пропущенная скобка секции — обычная опечатка, а ветка эта самая // уязвимая: именно в ней будущая правка легче всего протащит текст библиотеки // обратно. func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) { const secret = "AQVNsecret-api-key-value" // У секции не закрыта скобка: разбор оборвётся, не назвав ни одного ключа. path := writeConfig(t, "[yandex\nfolder_id = \"b1g\"\nspeech_kit_api_key = \""+secret+"\"\n") _, err := LoadConfig(path) if err == nil { t.Fatal("поломанный файл настроек принят") } message := err.Error() for _, part := range []string{secret, "AQVNsecret", "secret-api-key-value"} { if strings.Contains(message, part) { t.Fatalf("значение ключа доступа уехало в отказ: %v", err) } } if !strings.Contains(message, "строке") { t.Fatalf("место отказа не названо, чинить нечего: %v", err) } } // Числа конвейера приезжают из файла, а не выдумываются кодом. func TestLoadConfigReadsPipelineSettings(t *testing.T) { path := writeConfig(t, ` [pipeline] workers = 7 own_work_limit_minutes = 90 foreign_work_limit_minutes = 720 [storage] data_dir = "data" `) cfg, err := LoadConfig(path) if err != nil { t.Fatalf("конфиг не прочитан: %v", err) } if cfg.Pipeline.Workers != 7 { t.Errorf("число воркеров не прочитано: %d", cfg.Pipeline.Workers) } limits := cfg.Pipeline.StuckLimits() if limits.Own != 90*time.Minute { t.Errorf("предел своей работы не прочитан: %v", limits.Own) } if limits.Foreign != 720*time.Minute { t.Errorf("предел чужой работы не прочитан: %v", limits.Foreign) } } // Умолчания есть у всех трёх чисел: файл без секции конвейера годен, и сервис // поднимается с рабочими значениями. func TestPipelineSettingsHaveDefaults(t *testing.T) { path := writeConfig(t, "[storage]\ndata_dir = \"data\"\n") cfg, err := LoadConfig(path) if err != nil { t.Fatalf("конфиг не прочитан: %v", err) } if cfg.Pipeline.Workers <= 0 { t.Errorf("умолчание числа воркеров негодно: %d", cfg.Pipeline.Workers) } if err := cfg.Pipeline.Validate(); err != nil { t.Errorf("умолчания не проходят собственную проверку: %v", err) } } // Ноль воркеров — объявленный режим, а отрицательное число и нулевой предел — // опечатка: подниматься с ней значит остановить всякую запись первым же // захватом. func TestPipelineValidateSeparatesModeFromTypo(t *testing.T) { valid := PipelineConfig{Workers: 0, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 1440} if err := valid.Validate(); err != nil { t.Errorf("ноль воркеров объявлен законным значением: %v", err) } for name, cfg := range map[string]PipelineConfig{ "отрицательное число воркеров": {Workers: -1, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 1440}, "нулевой предел своей работы": {Workers: 1, OwnWorkLimitMinutes: 0, ForeignWorkLimitMinutes: 1440}, "нулевой предел чужой работы": {Workers: 1, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 0}, } { if err := cfg.Validate(); err == nil { t.Errorf("%s принято за режим", name) } } } // Настройки хранилища проверяются на старте, и каждая ветвь проверки закрывает // свою поломку. Ноль и отрицательное — опечатка, а не режим: нулевое ожидание // отдаёт «база занята» первому же воркеру, нулевой пул чтения означает пул без // предела, а пустой каталог данных оставляет сервис без места под базу и файлы. // Без проверки такая опечатка проявилась бы отказом под нагрузкой, а не на // подъёме. func TestStorageConfigValidate(t *testing.T) { valid := StorageConfig{DataDir: "data", BusyTimeoutMs: 5000, ReadConnections: 4} if err := valid.Validate(); err != nil { t.Fatalf("заполненные настройки отвергнуты: %v", err) } cases := []struct { name string config StorageConfig mention string }{ {"каталог данных не заполнен", StorageConfig{DataDir: "", BusyTimeoutMs: 5000, ReadConnections: 4}, "data_dir"}, {"каталог данных из одних пробелов", StorageConfig{DataDir: " ", BusyTimeoutMs: 5000, ReadConnections: 4}, "data_dir"}, {"ожидание нулевое", StorageConfig{DataDir: "data", BusyTimeoutMs: 0, ReadConnections: 4}, "busy_timeout_ms"}, {"ожидание отрицательное", StorageConfig{DataDir: "data", BusyTimeoutMs: -1, ReadConnections: 4}, "busy_timeout_ms"}, {"пул чтения нулевой", StorageConfig{DataDir: "data", BusyTimeoutMs: 5000, ReadConnections: 0}, "read_connections"}, {"пул чтения отрицательный", StorageConfig{DataDir: "data", BusyTimeoutMs: 5000, ReadConnections: -3}, "read_connections"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := tc.config.Validate() if err == nil { t.Fatal("негодная настройка принята: поломка дошла бы до боя") } if !strings.Contains(err.Error(), tc.mention) { t.Fatalf("имя ключа %q не названо: %v", tc.mention, err) } }) } }