Files
transcriber/internal/adapter/telegram/bot_test.go
T
av b733a84d6a telegram: сервис поднимается без бота и работает одним входом
- Клиент бота собирается один раз и достаётся отправителю и транспорту;
  разрез прошёл по «ответил ли Telegram»: ответ «такого бота нет» роняет
  старт, недоступность даёт подъём без Telegram (ADR-2026-08-13). Ожидание
  при сборке ограничено сроком — иначе молчащий Telegram вешал подъём.
- Недоставленный ответ не роняет шаг: пишется с job_id и считается метрикой,
  уровень по причине — WARN для неподнятого входа, ERROR для неназванного
  адресата. Заведены transcriber_intake_up и transcriber_undelivered_reply_count.
- Закрыта утечка токена в журнал: отказ разбора адреса рождается раньше
  обращения к клиенту, то есть мимо чистки на его границе.
2026-08-13 19:10:08 +03:00

173 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
"строка библиотеки прошла мимо нашего журнала: логгер не подменён")
}