config: включение Telegram разведено с ключом доступа

- в секции [telegram] заведён обязательный ключ enabled: умолчания у него нет,
  файл без него негоден; bot_token стал только ключом доступа и при
  enabled = false не читается вовсе, а пустой при enabled = true роняет старт
- выключенный вход даёт подъём одним входом без единого обращения к Telegram и
  записью INFO вместо прежнего WARN: это выбор владельца, а не отклонение
- отказ разбора файла настроек больше не пересказывает toml — её ParseError
  несёт в тексте разбираемое значение, и оборванная строка секретного ключа
  уносила его в журнал; теперь называются путь, строка, столбец и последний ключ
This commit is contained in:
av
2026-08-13 21:46:14 +03:00
parent 903941f587
commit cd57b68215
27 changed files with 1536 additions and 123 deletions
+65 -2
View File
@@ -1,6 +1,7 @@
package config
import (
"errors"
"fmt"
"net/url"
"os"
@@ -41,11 +42,33 @@ type YandexConfig struct {
ObjStorageEndpoint string `toml:"object_storage_endpoint"`
}
// TelegramConfig — вход Telegram. Признак включения объявляет намерение
// владельца, `BotToken` означает только доступ. Пока два значения жили в одном
// поле, пустой токен читался разом как «вход выключен» и как «ключ не доехал»,
// и сервис поднимался без бота в обоих случаях.
type TelegramConfig struct {
// Enabled — умолчания у него нет **намеренно**, и потому его нет в
// `defaultConfig()`: умолчание было бы угаданным намерением, а признак
// заведён затем, чтобы намерение объявляли. Отсутствие ключа в файле ловит
// `LoadConfig` — нулевое значение `bool` режима не выбирает.
Enabled bool `toml:"enabled"`
BotToken string `toml:"bot_token"`
UpdateTimeout int `toml:"update_timeout"`
}
// Validate проверяет ключ доступа против объявленного намерения. Пустой ключ
// при включённом входе — ошибка настройки: бот по нему не появится, а тихий
// подъём без бота оставил бы отправителей без ответов.
//
// Названо имя ключа, а не значение: значение `bot_token` в журнал попасть не
// должно.
func (c TelegramConfig) Validate() error {
if c.Enabled && c.BotToken == "" {
return errors.New("telegram: не заполнен ключ bot_token при enabled = true")
}
return nil
}
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
@@ -129,6 +152,7 @@ func defaultConfig() *Config {
ObjStorageRegion: "ru-central1",
ObjStorageEndpoint: "https://storage.yandexcloud.net/",
},
// Умолчания у `Enabled` здесь нет намеренно — причина у поля.
Telegram: TelegramConfig{
BotToken: "",
UpdateTimeout: 10,
@@ -149,9 +173,48 @@ func LoadConfig(path string) (*Config, error) {
config := defaultConfig()
// Load configuration from file
if _, err := toml.DecodeFile(path, &config); err != nil {
return nil, fmt.Errorf("failed to decode config file: %w", err)
meta, err := toml.DecodeFile(path, &config)
if err != nil {
return nil, decodeError(path, err)
}
// Признак включения входа Telegram обязателен: умолчания у него нет, и
// отличить «не задан» от «задан ложным» умеет только разбор — нулевое
// значение `bool` в структуре у обоих одинаковое. Отсюда и `meta`: наружу
// она не отдаётся, приговор выносится здесь.
if !meta.IsDefined("telegram", "enabled") {
return nil, errors.New("telegram: не задан ключ enabled; он объявляет, нужен ли сервису вход Telegram")
}
return config, nil
}
// decodeError переводит отказ разбора на свои слова. Пересказывать библиотеку
// нельзя: она собирает текст отказа из разбираемого куска файла, и оборванная
// строка секретного ключа уехала бы в журнал вместе со значением.
//
// Разрез идёт по семейству отказа, и значения несёт только одно:
//
// - `toml.ParseError` — сюда сведены отказы лексера и разбора значения, а его
// `Message` собран из разбираемого куска («Invalid float value: %q»,
// «invalid duration: %q»). Берём строку, столбец и последний ключ — они
// безопасны, — а `Message` не берём;
// - прочие отказы декодера собраны из имён ключей и имён типов, значений в них
// нет вовсе. Их текст берём как есть: выбросив его, мы заплатили бы
// разборчивостью отказа там, где платить не за что.
//
// Две ветки не сводятся в одну намеренно. Сведённая к общему знаменателю, она
// либо вернёт утечку, либо оставит несовпадение типов без единого намёка.
func decodeError(path string, err error) error {
var parseErr toml.ParseError
if errors.As(err, &parseErr) {
if parseErr.LastKey != "" {
return fmt.Errorf("config file %s: разбор оборвался на строке %d, столбце %d, последний ключ %q",
path, parseErr.Position.Line, parseErr.Position.Col, parseErr.LastKey)
}
return fmt.Errorf("config file %s: разбор оборвался на строке %d, столбце %d",
path, parseErr.Position.Line, parseErr.Position.Col)
}
return fmt.Errorf("failed to decode config file %s: %w", path, err)
}
+179
View File
@@ -1,6 +1,9 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -95,3 +98,179 @@ func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) {
})
}
}
// Признак включения объявляет намерение, ключ доступа означает только доступ.
// Пока эти два значения жили в одном поле, пустой токен читался разом как
// «вход выключен» и как «ключ не доехал».
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)
}
}