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