- контекст проложен от воркера и обоих входов до внешних вызовов: ffmpeg и ffprobe заводятся через exec.CommandContext, SpeechKit и Object Storage принимают ctx вместо context.Background, скачивание записи идёт запросом с контекстом. Прежде остановка сервиса не доходила до чужой работы вовсе - прерванный шаг приговора не выносит: убитый по контексту ffmpeg отдаёт «signal: killed», от настоящего отказа неотличимо ни типом, ни errors.Is, и различает их только ctx.Err(). Задача остаётся на повтор, попытку не тратит и отправителю о несуществующем сбое не сообщает; воркер не считает остановку отказом, а задача не забирается вовсе, если нас уже остановили - клиента Bot API заводит единая точка internal/adapter/telegram: токен стоит в пути каждого обращения, а http.Client кладёт адрес в *url.Error целиком. Чистка на месте употребления закрывала один вызов из пяти — теперь свой Do чистит отказ, подменённый логгер вычищает токен из строк самой библиотеки, а транспорт бота токена не получает вовсе - принятие операции распознавания защищено от отмены своим пределом: SpeechKit мог её принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз - приём по HTTP доводит запись до задачи независимо от отправителя: на контексте запроса один обрыв соединения терял полностью загруженную запись - ответ Telegram с не-2xx кодом больше не становится записью: прежде тело отказа доезжало до хранилища и умирало на ffprobe, уводя диагностику
141 lines
6.7 KiB
Go
141 lines
6.7 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)
|
||
})
|
||
}
|
||
|
||
// Отказ конструктора несёт тот же путь: `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)
|
||
}
|
||
|
||
// 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",
|
||
"строка библиотеки прошла мимо нашего журнала: логгер не подменён")
|
||
}
|