- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
258 lines
11 KiB
Go
258 lines
11 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|