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) } } }