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