axe/internal/handlers/telegram_test.go
2026-10-18 22:07:25 +03:00

381 lines
14 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 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)
}
}
}