package config import ( "fmt" "os" "path/filepath" "strings" "testing" ) // Проверка входа — единственная страховка от того, чтобы сервис поднялся с // молча выключенным входом, то есть открытым наружу. До этих проверок она не // исполнялась ни разу. func validAuthConfig() AuthConfig { return AuthConfig{ AuthURL: "https://auth.example.com/api/oidc/authorization", TokenURL: "https://auth.example.com/api/oidc/token", UserInfoURL: "https://auth.example.com/api/oidc/userinfo", ClientID: "transcriber", ClientSecret: "secret-value", RedirectURL: "https://transcriber.example.com/auth/callback", } } func TestAuthConfigValidateAcceptsFilled(t *testing.T) { if err := validAuthConfig().Validate(); err != nil { t.Fatalf("заполненный конфиг отвергнут: %v", err) } } func TestAuthConfigValidateNamesEveryMissingKey(t *testing.T) { cases := map[string]func(*AuthConfig){ "auth_url": func(c *AuthConfig) { c.AuthURL = "" }, "token_url": func(c *AuthConfig) { c.TokenURL = "" }, "user_info_url": func(c *AuthConfig) { c.UserInfoURL = "" }, "client_id": func(c *AuthConfig) { c.ClientID = "" }, "client_secret": func(c *AuthConfig) { c.ClientSecret = "" }, "redirect_url": func(c *AuthConfig) { c.RedirectURL = "" }, } for key, clear := range cases { t.Run(key, func(t *testing.T) { cfg := validAuthConfig() clear(&cfg) err := cfg.Validate() if err == nil { t.Fatalf("пустой ключ %s пропущен — сервис поднимется с выключенным входом", key) } if !strings.Contains(err.Error(), key) { t.Fatalf("имя ключа %s не названо: %v", key, err) } }) } } // TestAuthConfigValidateHidesSecretValue: сообщение об отказе уезжает в журнал, // и значения секрета в нём быть не должно — только имя ключа. func TestAuthConfigValidateHidesSecretValue(t *testing.T) { cfg := validAuthConfig() cfg.ClientSecret = "super-secret-value" cfg.AuthURL = "" err := cfg.Validate() if err == nil { t.Fatal("отказа нет") } if strings.Contains(err.Error(), "super-secret-value") { t.Fatalf("значение секрета попало в текст отказа: %v", err) } } // TestAuthConfigValidateRejectsMalformedURL: непустая строка, не похожая на // адрес, отвергается здесь, а не позже — хранилище отказало бы уже из хука // подъёма, до регистрации пробы здоровья, и сервис упал бы молча целиком. func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) { cases := map[string]string{ "без схемы": "auth.example.com/api/oidc/authorization", "пробел спереди": " https://auth.example.com/authorize", "чужая схема": "ftp://auth.example.com/authorize", "пустой хост": "https:///authorize", "не адрес вовсе": "todo: заполнить", } for name, value := range cases { t.Run(name, func(t *testing.T) { cfg := validAuthConfig() cfg.AuthURL = value err := cfg.Validate() if err == nil { t.Fatalf("негодный адрес %q пропущен", value) } if !strings.Contains(err.Error(), "auth_url") { t.Fatalf("имя ключа не названо: %v", err) } }) } } // Признак включения объявляет намерение, ключ доступа означает только доступ. // Пока эти два значения жили в одном поле, пустой токен читался разом как // «вход выключен» и как «ключ не доехал». func TestTelegramConfigValidateAcceptsEnabledWithToken(t *testing.T) { cfg := TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"} if err := cfg.Validate(); err != nil { t.Fatalf("включённый вход с ключом отвергнут: %v", err) } } func TestTelegramConfigValidateRejectsEnabledWithoutToken(t *testing.T) { cfg := TelegramConfig{Enabled: true, BotToken: ""} err := cfg.Validate() if err == nil { t.Fatal("включённый вход без ключа доступа пропущен") } if !strings.Contains(err.Error(), "bot_token") { t.Fatalf("имя ключа не названо: %v", err) } } // Выключенный вход на ключ доступа не смотрит вовсе: пустой ключ при нём — // обычное состояние локального прогона, а не ошибка настройки. func TestTelegramConfigValidateIgnoresTokenWhenDisabled(t *testing.T) { cfg := TelegramConfig{Enabled: false, BotToken: ""} if err := cfg.Validate(); err != nil { t.Fatalf("выключенный вход без ключа отвергнут: %v", err) } } // Половина требования «сообщение не несёт значения ключа» на этой проверке // **вакуумна**, и честнее это назвать, чем изображать сторожа. // // `Validate()` отказывает ровно на пустом ключе — значения, которым можно // проговориться, на этом пути не существует. Прежняя редакция сторожа искала // подстроку, которой в сообщении нет ни при каком входе, и потому не могла // упасть вовсе: правка на `%q` от токена оставила бы её зелёной. В проекте это // третий пойманный случай проверки, не способной упасть. // // Настоящий сторож той же нормы живёт там, где непустой ключ в отказ попасть // действительно может, — `TestLoadConfigMalformedSecretLineHidesValue` и // `TestLoadConfigMalformedBeforeAnyKeyHidesValue`. Здесь проверяется то, что // проверяемо: заполненный ключ проходит, пустой отвергается с именем ключа. func TestTelegramConfigValidateNamesKeyWithoutValue(t *testing.T) { filled := TelegramConfig{Enabled: true, BotToken: "123456:AAHfake-secret-token-value"} if err := filled.Validate(); err != nil { t.Fatalf("включённый вход с заполненным ключом отвергнут: %v", err) } err := TelegramConfig{Enabled: true, BotToken: ""}.Validate() if err == nil { t.Fatal("включённый вход без ключа доступа пропущен") } if !strings.Contains(err.Error(), "bot_token") { t.Fatalf("имя ключа не названо: %v", err) } } 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 = ` [telegram] enabled = false bot_token = "" ` // Признак обязателен: файл без него негоден. Умолчание было бы угаданным // намерением, а отличить «не задан» от «задан ложным» умеет только разбор — // нулевое значение bool у обоих одинаковое. func TestLoadConfigRejectsMissingTelegramEnabled(t *testing.T) { path := writeConfig(t, "[telegram]\nbot_token = \"123456:AA-fake\"\n") _, err := LoadConfig(path) if err == nil { t.Fatal("файл без признака включения принят") } if !strings.Contains(err.Error(), "enabled") { t.Fatalf("имя недостающего ключа не названо: %v", err) } } func TestLoadConfigReadsBothValuesOfTelegramEnabled(t *testing.T) { for _, enabled := range []bool{true, false} { t.Run(fmt.Sprintf("%t", enabled), func(t *testing.T) { body := fmt.Sprintf("[telegram]\nenabled = %t\nbot_token = \"123456:AA-fake\"\n", enabled) cfg, err := LoadConfig(writeConfig(t, body)) if err != nil { t.Fatalf("годный файл отвергнут: %v", err) } if cfg.Telegram.Enabled != enabled { t.Fatalf("признак доехал как %t, а в файле %t", cfg.Telegram.Enabled, enabled) } }) } } // Инвариант «секрет не покидает конфиг»: текст отказа разбора собирает чужая // библиотека из разбираемого куска файла, и оборванная строка ключа доступа // уехала бы в журнал вместе со значением. Отсюда собственное сообщение. func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) { const secret = "123456:AAHfake-secret-token-value" // Кавычка не закрыта: разбор оборвётся на значении. path := writeConfig(t, "[telegram]\nenabled = true\nbot_token = \""+secret+"\n") _, err := LoadConfig(path) if err == nil { t.Fatal("поломанный файл настроек принят") } message := err.Error() for _, part := range []string{secret, "AAHfake", "secret-token-value", "123456"} { if strings.Contains(message, part) { t.Fatalf("значение ключа доступа уехало в отказ: %v", err) } } // Без места и ключа отказ нечинибелен: скрыть значение мало. if !strings.Contains(message, "строке 3") { t.Fatalf("номер строки не назван, чинить нечего: %v", err) } if !strings.Contains(message, "bot_token") { 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 = "123456:AAHsecret-token-value" // У секции не закрыта скобка: разбор оборвётся, не назвав ни одного ключа. path := writeConfig(t, "[telegram\nenabled = true\nbot_token = \""+secret+"\"\n") _, err := LoadConfig(path) if err == nil { t.Fatal("поломанный файл настроек принят") } message := err.Error() for _, part := range []string{secret, "AAHsecret", "secret-token-value"} { if strings.Contains(message, part) { t.Fatalf("значение ключа доступа уехало в отказ: %v", err) } } if !strings.Contains(message, "строке") { t.Fatalf("место отказа не названо, чинить нечего: %v", err) } }