- Клиент бота собирается один раз и достаётся отправителю и транспорту; разрез прошёл по «ответил ли Telegram»: ответ «такого бота нет» роняет старт, недоступность даёт подъём без Telegram (ADR-2026-08-13). Ожидание при сборке ограничено сроком — иначе молчащий Telegram вешал подъём. - Недоставленный ответ не роняет шаг: пишется с job_id и считается метрикой, уровень по причине — WARN для неподнятого входа, ERROR для неназванного адресата. Заведены transcriber_intake_up и transcriber_undelivered_reply_count. - Закрыта утечка токена в журнал: отказ разбора адреса рождается раньше обращения к клиенту, то есть мимо чистки на его границе.
173 lines
8.4 KiB
Go
173 lines
8.4 KiB
Go
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",
|
||
"строка библиотеки прошла мимо нашего журнала: логгер не подменён")
|
||
}
|