axe/internal/handlers/telegram_test.go

381 lines
14 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
package handlers
import (
"context"
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"building-mgmt/internal/crypt"
"building-mgmt/internal/telegram"
)
// newTelegramContext — контекст запроса к Telegram-хендлеру с id юзера,
// как он есть после auth.Middleware.
func newTelegramContext(t *testing.T, method, path, body string) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(method, path, strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("user_id", "11111111-1111-1111-1111-111111111111")
return c, w
}
// fakeTelegramBot — бот без сети: записи вызовов нужны, чтобы видеть,
// что формат цели проверился до базы.
type fakeTelegramBot struct {
enabled bool
username string
initErr error
chatErr error
initCalls int
chats []string
sent []string
}
func (f *fakeTelegramBot) Enabled() bool { return f.enabled }
func (f *fakeTelegramBot) Username() string { return f.username }
func (f *fakeTelegramBot) Init(context.Context) error {
f.initCalls++
if f.initErr != nil {
return f.initErr
}
if f.username == "" {
f.username = "test_bot"
}
return nil
}
func (f *fakeTelegramBot) SendMessage(_ context.Context, chat, _ string) error {
f.sent = append(f.sent, chat)
return nil
}
func (f *fakeTelegramBot) GetChat(_ context.Context, target string) error {
f.chats = append(f.chats, target)
return f.chatErr
}
func (f *fakeTelegramBot) GetUpdates(context.Context, int64, time.Duration) ([]telegram.Update, error) {
return nil, nil
}
// fakeRegistry — BotRegistry без поллеров: запасной (глобальный) бот
// плюс личные по пользователям, записи вызовов видно в tests.
type fakeRegistry struct {
global telegram.API
users map[string]telegram.API
registered []string
unregistered []string
}
func (f *fakeRegistry) BotFor(userID string) telegram.API {
if b, ok := f.users[userID]; ok {
return b
}
return f.global
}
func (f *fakeRegistry) Register(userID string, bot telegram.API) {
if f.users == nil {
f.users = map[string]telegram.API{}
}
f.users[userID] = bot
f.registered = append(f.registered, userID)
}
func (f *fakeRegistry) Unregister(userID string) {
delete(f.users, userID)
f.unregistered = append(f.unregistered, userID)
}
// fakeAllow — Allower с заданным результатом.
type fakeAllow bool
func (f fakeAllow) Allow(string) bool { return bool(f) }
// TestSetTargetValidation — некорректная цель не доходит ни до getChat,
// ни до базы (repo = nil упал бы): только формат и 400.
func TestSetTargetValidation(t *testing.T) {
for _, target := range []string{
"buildings", // без @ и не число
"@ab", // короче 5 знаков
"@ссылка", // не латиница
"@group name", // пробел
"https://t.me/x", // не имя чата
"12", // короткое число
"@" + strings.Repeat("a", 33), // слишком длинное имя (>32)
} {
t.Run(target, func(t *testing.T) {
h := &TelegramHandler{bots: &fakeRegistry{global: &fakeTelegramBot{}}, codeLimiter: fakeAllow(true)}
c, w := newTelegramContext(t, http.MethodPut, "/auth/telegram",
`{"target":"`+target+`"}`)
h.Set(c)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
if body := w.Body.String(); !strings.Contains(body, "invalid telegram target") {
t.Errorf("body = %s", body)
}
})
}
}
// TestSetTargetBotCheck — включённый бот проверяет чат getChat'ом:
// недоступная цель → 400 и в базу не пишем.
func TestSetTargetBotCheck(t *testing.T) {
bot := &fakeTelegramBot{enabled: true, username: "test_bot",
chatErr: telegram.ErrChatNotFound}
h := &TelegramHandler{bots: &fakeRegistry{global: bot}, codeLimiter: fakeAllow(true)}
c, w := newTelegramContext(t, http.MethodPut, "/auth/telegram",
`{"target":"@buildings_group"}`)
h.Set(c)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
if len(bot.chats) != 1 || bot.chats[0] != "@buildings_group" {
t.Errorf("getChat calls = %v, want [@buildings_group]", bot.chats)
}
}
// TestSetTargetBadBody — кривой JSON и не тот тип поля — 400 до всякой
// логики (стандартная форма ошибки binding).
func TestSetTargetBadBody(t *testing.T) {
for _, body := range []string{`{"target":`, `{"target":5}`, `[]`} {
t.Run(body, func(t *testing.T) {
h := &TelegramHandler{bots: &fakeRegistry{global: &fakeTelegramBot{}}, codeLimiter: fakeAllow(true)}
c, w := newTelegramContext(t, http.MethodPut, "/auth/telegram", body)
h.Set(c)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
})
}
}
// TestIssueCodeGuards — защитные проверки до базы: фича выключена,
// лимит кодов исчерпан, getMe недоступен.
func TestIssueCodeGuards(t *testing.T) {
t.Run("бот не настроен", func(t *testing.T) {
h := &TelegramHandler{bots: &fakeRegistry{global: &fakeTelegramBot{enabled: false}}, codeLimiter: fakeAllow(true)}
c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/code", `{}`)
h.IssueCode(c)
if w.Code != http.StatusBadRequest ||
!strings.Contains(w.Body.String(), "telegram not configured") {
t.Errorf("status = %d, body = %s", w.Code, w.Body.String())
}
})
t.Run("лимит кодов", func(t *testing.T) {
bot := &fakeTelegramBot{enabled: true, username: "test_bot"}
h := &TelegramHandler{bots: &fakeRegistry{global: bot}, codeLimiter: fakeAllow(false)}
c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/code", `{}`)
h.IssueCode(c)
if w.Code != http.StatusTooManyRequests {
t.Errorf("status = %d, want 429", w.Code)
}
if bot.initCalls != 0 {
t.Errorf("Init calls = %d, want 0 (лимит раньше сети)", bot.initCalls)
}
})
t.Run("getMe недоступен", func(t *testing.T) {
bot := &fakeTelegramBot{enabled: true, initErr: context.DeadlineExceeded}
h := &TelegramHandler{bots: &fakeRegistry{global: bot}, codeLimiter: fakeAllow(true)}
c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/code", `{}`)
h.IssueCode(c)
if w.Code != http.StatusInternalServerError ||
!strings.Contains(w.Body.String(), "telegram unavailable") {
t.Errorf("status = %d, body = %s", w.Code, w.Body.String())
}
if bot.initCalls != 1 {
t.Errorf("Init calls = %d, want 1 (второй шанс getMe)", bot.initCalls)
}
})
}
// testCipher — рабочий шифровальщик для тестов токена (нужен, чтобы
// хендлер вообще дошёл до формата и сети).
func testCipher(t *testing.T) *crypt.Cipher {
t.Helper()
c, err := crypt.New(base64.StdEncoding.EncodeToString(make([]byte, 32)))
if err != nil {
t.Fatalf("crypt.New: %v", err)
}
return c
}
// validBotToken — формат, который проходит проверку до сети.
const validBotToken = "123456789:AAFakeTokenForTests_0123456789"
// TestSetBotGuards — защитные проверки подключения бота: без ключа
// шифрования фича выключена, кривой формат и лимит срабатывают раньше
// сети, отвергнутый getMe не доходит ни до базы, ни до реестра
// (repo = nil упал бы).
func TestSetBotGuards(t *testing.T) {
const body = `{"token":"` + validBotToken + `"}`
t.Run("нет ключа шифрования", func(t *testing.T) {
h := &TelegramHandler{bots: &fakeRegistry{}, tokenLimiter: fakeAllow(true)}
c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/bot", body)
h.SetBot(c)
if w.Code != http.StatusBadRequest ||
!strings.Contains(w.Body.String(), "telegram bot token not configured") {
t.Errorf("status = %d, body = %s", w.Code, w.Body.String())
}
})
t.Run("кривой формат токена", func(t *testing.T) {
bot := &fakeTelegramBot{enabled: true}
h := &TelegramHandler{
bots: &fakeRegistry{},
cipher: testCipher(t),
newBot: func(string) telegram.API { return bot },
tokenLimiter: fakeAllow(true),
}
c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/bot",
`{"token":"не токен"}`)
h.SetBot(c)
if w.Code != http.StatusBadRequest ||
!strings.Contains(w.Body.String(), "invalid bot token") {
t.Errorf("status = %d, body = %s", w.Code, w.Body.String())
}
if bot.initCalls != 0 {
t.Errorf("Init calls = %d, want 0 (формат раньше сети)", bot.initCalls)
}
})
t.Run("лимит попыток", func(t *testing.T) {
bot := &fakeTelegramBot{enabled: true}
h := &TelegramHandler{
bots: &fakeRegistry{},
cipher: testCipher(t),
newBot: func(string) telegram.API { return bot },
tokenLimiter: fakeAllow(false),
}
c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/bot", body)
h.SetBot(c)
if w.Code != http.StatusTooManyRequests {
t.Errorf("status = %d, want 429", w.Code)
}
if bot.initCalls != 0 {
t.Errorf("Init calls = %d, want 0 (лимит раньше сети)", bot.initCalls)
}
})
t.Run("getMe отверг токен", func(t *testing.T) {
bot := &fakeTelegramBot{enabled: true, initErr: context.DeadlineExceeded}
h := &TelegramHandler{
bots: &fakeRegistry{},
cipher: testCipher(t),
newBot: func(string) telegram.API { return bot },
tokenLimiter: fakeAllow(true),
}
c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/bot", body)
h.SetBot(c)
if w.Code != http.StatusBadRequest ||
!strings.Contains(w.Body.String(), "bot token rejected") {
t.Errorf("status = %d, body = %s", w.Code, w.Body.String())
}
if bot.initCalls != 1 {
t.Errorf("Init calls = %d, want 1", bot.initCalls)
}
})
t.Run("кривое тело", func(t *testing.T) {
h := &TelegramHandler{
bots: &fakeRegistry{},
cipher: testCipher(t),
newBot: func(string) telegram.API { return &fakeTelegramBot{} },
tokenLimiter: fakeAllow(true),
}
for _, raw := range []string{`{"token":`, `{"token":5}`, `[]`} {
c, w := newTelegramContext(t, http.MethodPost, "/auth/telegram/bot", raw)
h.SetBot(c)
if w.Code != http.StatusBadRequest {
t.Errorf("body %s: status = %d, want 400", raw, w.Code)
}
}
})
}
// TestSetUsesPersonalBot — цель проверяет бот пользователя, а не
// глобальный: чужой для него чат должен дать 400, и ни один из ботов
// не должен молча пройти мимо проверки.
func TestSetUsesPersonalBot(t *testing.T) {
const user = "11111111-1111-1111-1111-111111111111"
mine := &fakeTelegramBot{enabled: true, username: "mine_bot",
chatErr: telegram.ErrChatNotFound}
global := &fakeTelegramBot{enabled: true, username: "global_bot"}
h := &TelegramHandler{
bots: &fakeRegistry{global: global, users: map[string]telegram.API{user: mine}},
codeLimiter: fakeAllow(true),
tokenLimiter: fakeAllow(true),
}
c, w := newTelegramContext(t, http.MethodPut, "/auth/telegram",
`{"target":"@buildings_group"}`)
h.Set(c)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
if len(mine.chats) != 1 || mine.chats[0] != "@buildings_group" {
t.Errorf("personal bot chats = %v, want [@buildings_group]", mine.chats)
}
if len(global.chats) != 0 {
t.Errorf("global bot chats = %v, want none (проверял личный)", global.chats)
}
}
// TestRandomCode — код привязки: длина, алфавит без похожих символов.
func TestRandomCode(t *testing.T) {
for i := 0; i < 200; i++ {
code, err := randomCode()
if err != nil {
t.Fatalf("randomCode: %v", err)
}
if len(code) != bindCodeLen {
t.Fatalf("len = %d, want %d", len(code), bindCodeLen)
}
for _, r := range code {
if !strings.ContainsRune(bindCodeAlphabet, r) {
t.Fatalf("символ %q вне алфавита (%s)", r, code)
}
}
}
}
// TestTargetPattern — пограничные значения формата цели.
func TestTargetPattern(t *testing.T) {
for _, tc := range []struct {
target string
valid bool
}{
{"@buildings", true},
{"@Build_123", true},
{"-1001234567890", true}, // группа
{"123456789", true}, // личный chat_id
{"", true}, // пусто = отключить (проверяется отдельно)
{"@abc", false}, // слишком короткое имя
{"@_" + strings.Repeat("a", 32), false}, // 35 знаков
{"123", false}, // слишком короткое число
{"@имя", false}, // не латиница
} {
got := tc.target == "" || targetPattern.MatchString(tc.target)
if got != tc.valid {
t.Errorf("target %q: valid = %v, want %v", tc.target, got, tc.valid)
}
}
}