package telegram import ( "bytes" "errors" "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "testing" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Токен виден в пути каждого обращения к Bot API, а `http.Client` кладёт путь в // `*url.Error` целиком. Проверки ниже судят по тексту: секрет не должен // встречаться ни в отказе, ни в строке журнала. Утечка необратима — утёкший // токен отзывают руками (CLAUDE.md, «Инварианты», critical). const probeToken = "7654321:AAHsecretBOTtokenVALUE" // getMeResponse — ответ, которым подставной Telegram пускает конструктор // дальше: `NewBotAPIWithClient` ходит за `getMe` прежде, чем отдать клиента. const getMeResponse = `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"probe","username":"probe_bot"}}` func newProbeBot(t *testing.T, handler http.HandlerFunc) (*tgbotapi.BotAPI, *httptest.Server) { t.Helper() server := httptest.NewServer(handler) t.Cleanup(server.Close) bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler)) require.NoError(t, err) return bot, server } // Отказ транспорта на любом вызове Bot API не несёт токена: чистка стоит на // границе клиента, а не у места употребления, поэтому закрыты все вызовы разом. func TestBotAPIFailureDoesNotCarryToken(t *testing.T) { bot, server := newProbeBot(t, func(w http.ResponseWriter, _ *http.Request) { if _, err := w.Write([]byte(getMeResponse)); err != nil { t.Errorf("подставной Telegram не смог ответить: %v", err) } }) // Собеседник исчез — так выглядит обрыв сети, DNS-сбой и недоступность // api.telegram.org. server.Close() t.Run("getFile", func(t *testing.T) { _, err := bot.GetFile(tgbotapi.FileConfig{FileID: "any"}) require.Error(t, err) assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err) }) t.Run("sendMessage", func(t *testing.T) { _, err := bot.Send(tgbotapi.NewMessage(1, "текст")) require.Error(t, err) assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ: %v", err) }) } // Токен, ломающий разбор адреса, — второй путь отказа конструктора, и до // недавнего он был открыт: `http.NewRequest` падает раньше обращения к клиенту, // то есть мимо чистки на его границе. Так выглядит перенос строки, приехавший // с секретом из шаблона выкладки, и невычищенная `%`-последовательность. func TestBotConstructionFailureOnUnparsableTokenDoesNotCarryToken(t *testing.T) { broken := map[string]string{ "перенос строки": probeToken + "\n", "негодная escape-пара": "7654321:AAH%zzSECRETtokenVALUE", } for name, token := range broken { t.Run(name, func(t *testing.T) { _, err := newBot(token, tgbotapi.APIEndpoint, slog.New(slog.DiscardHandler)) require.Error(t, err) assert.NotContains(t, err.Error(), token, "токен уехал в отказ: %v", err) assert.NotContains(t, err.Error(), "api.telegram.org", "адрес остался в отказе: %v", err) }) } } // Отказ конструктора несёт тот же путь: `NewBotAPIWithClient` ходит за `getMe`, // и контейнер, стартующий раньше сети, печатал бы токен в первую же секунду. func TestBotConstructionFailureDoesNotCarryToken(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) server.Close() _, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler)) require.Error(t, err) assert.NotContains(t, err.Error(), probeToken, "токен уехал в отказ конструктора: %v", err) } // Длинный опрос печатает свои отказы пакетным логгером самой библиотеки, минуя // наш `slog`. Логгер подменён — значит, и эта строка идёт через вычистку. func TestLibraryLoggerRedactsToken(t *testing.T) { journal := &bytes.Buffer{} logger := slog.New(slog.NewTextHandler(journal, nil)) redacting := &redactingLogger{token: probeToken, logger: logger} redacting.Println(errors.New(`Post "https://api.telegram.org/bot` + probeToken + `/getUpdates": dial tcp: refused`)) redacting.Printf("Failed to get updates from %s", "https://api.telegram.org/bot"+probeToken+"/getUpdates") written := journal.String() assert.NotContains(t, written, probeToken, "токен уехал в журнал: %s", written) assert.Equal(t, 2, strings.Count(written, redactedToken), "вместо токена стоит пометка") assert.Contains(t, written, "dial tcp", "причина отказа осталась") } // Пустой токен — законный исход подъёма без Telegram, и узнаётся он по смыслу. // Обратное тоже нормируется: отказ негодного токена не должен читаться как // отказ от входа, иначе сборка при старте подставит заглушку там, где нужен // отказ, и молча потеряет бота. func TestEmptyTokenIsRecognizedByValue(t *testing.T) { _, err := NewBot("", slog.New(slog.DiscardHandler)) require.ErrorIs(t, err, ErrEmptyToken) server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) server.Close() _, err = newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.DiscardHandler)) require.Error(t, err) require.NotErrorIs(t, err, ErrEmptyToken) } // WithoutURL снимает адрес, но не причину: `errors.Is` по цепочке продолжает // работать, иначе чистка стоила бы узнаваемости отказа. func TestWithoutURLKeepsCause(t *testing.T) { cause := errors.New("dial tcp: connection refused") wrapped := &url.Error{Op: "Post", URL: "https://api.telegram.org/bot" + probeToken + "/getMe", Err: cause} cleaned := WithoutURL(wrapped) assert.NotContains(t, cleaned.Error(), probeToken) require.ErrorIs(t, cleaned, cause) assert.Equal(t, cause, WithoutURL(cause), "отказ без адреса не трогают") } // Стык, которого не сторожил никто: подмена пакетного логгера держится одной // строкой в `NewBot`, а снятие этой строки не роняло ни одной проверки. Оракул // косвенный по необходимости — библиотека не отдаёт установленный логгер // обратно, — поэтому он смотрит на исход: её собственная строка обязана // оказаться в нашем журнале. func TestLibraryLoggerIsActuallyInstalled(t *testing.T) { journal := &bytes.Buffer{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { if _, err := w.Write([]byte(getMeResponse)); err != nil { t.Errorf("подставной Telegram не смог ответить: %v", err) } })) t.Cleanup(server.Close) bot, err := newBot(probeToken, server.URL+"/bot%s/%s", slog.New(slog.NewTextHandler(journal, nil))) require.NoError(t, err) // Отладку библиотека печатает тем же логгером, что и отказы: включаем её, // чтобы строка появилась без обрыва сети. bot.Debug = true _, err = bot.GetMe() require.NoError(t, err) assert.Contains(t, journal.String(), "Telegram library log", "строка библиотеки прошла мимо нашего журнала: логгер не подменён") }