Sprint 6: security hardening
1. CORS whitelist (was '*'): only buhapp.mygoodservice.ru, t.me, web.telegram.org 2. Rate limit on /auth/login (10/min/IP) and /auth/register (5/hour/IP) 3. TGHandler: removed unused JWTSecret, added WebhookSecret, real secret check 4. Login: constant-time bcrypt on user enumeration (dummy hash) 5. TgUsername saved in users (was lost) 6. User.IsVerified=true for Telegram users 7. Register: 409 instead of 500 on duplicate email 8. Bumped version to 0.4.0
This commit is contained in:
parent
c6d219c51f
commit
c30faf02ef
@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/limiter"
|
||||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||||
|
|
||||||
@ -84,7 +85,7 @@ func main() {
|
|||||||
var tgH *handlers.TGHandler
|
var tgH *handlers.TGHandler
|
||||||
if cfg.TelegramBotToken != "" {
|
if cfg.TelegramBotToken != "" {
|
||||||
bot = telegram.NewBot(cfg.TelegramBotToken)
|
bot = telegram.NewBot(cfg.TelegramBotToken)
|
||||||
tgH = handlers.NewTGHandler(bot, usersRepo, authSvc, consentRepo, auditRepo, []byte(cfg.JWTSecret))
|
tgH = handlers.NewTGHandler(bot, usersRepo, authSvc, consentRepo, auditRepo, cfg.TelegramWebhookSecret)
|
||||||
log.Println("telegram bot: configured")
|
log.Println("telegram bot: configured")
|
||||||
} else {
|
} else {
|
||||||
log.Println("telegram bot: NOT configured (set TELEGRAM_BOT_TOKEN)")
|
log.Println("telegram bot: NOT configured (set TELEGRAM_BOT_TOKEN)")
|
||||||
@ -107,16 +108,34 @@ func main() {
|
|||||||
app.Use(recover.New())
|
app.Use(recover.New())
|
||||||
app.Use(logger.New())
|
app.Use(logger.New())
|
||||||
app.Use(cors.New(cors.Config{
|
app.Use(cors.New(cors.Config{
|
||||||
AllowOrigins: "*",
|
AllowOrigins: "https://buhapp.mygoodservice.ru,https://t.me,https://web.telegram.org",
|
||||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
AllowHeaders: "Origin, Content-Type, Accept, Authorization, X-Telegram-Bot-Api-Secret-Token",
|
||||||
AllowMethods: "GET, POST, PUT, DELETE, OPTIONS",
|
AllowMethods: "GET, POST, PUT, DELETE, OPTIONS",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Rate limiters — защита от брутфорса login и register
|
||||||
|
loginLimiter := limiter.New(limiter.Config{
|
||||||
|
Max: 10,
|
||||||
|
Expiration: 1 * time.Minute,
|
||||||
|
KeyGenerator: func(c *fiber.Ctx) string { return c.IP() },
|
||||||
|
LimitReached: func(c *fiber.Ctx) error {
|
||||||
|
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "too many login attempts"})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
registerLimiter := limiter.New(limiter.Config{
|
||||||
|
Max: 5,
|
||||||
|
Expiration: 1 * time.Hour,
|
||||||
|
KeyGenerator: func(c *fiber.Ctx) string { return c.IP() },
|
||||||
|
LimitReached: func(c *fiber.Ctx) error {
|
||||||
|
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{"error": "too many registrations"})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
app.Get("/health", func(c *fiber.Ctx) error {
|
app.Get("/health", func(c *fiber.Ctx) error {
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"time": time.Now().UTC().Format(time.RFC3339),
|
"time": time.Now().UTC().Format(time.RFC3339),
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"db": pg.Pool != nil,
|
"db": pg.Pool != nil,
|
||||||
"redis": rdb != nil,
|
"redis": rdb != nil,
|
||||||
"storage": st != nil,
|
"storage": st != nil,
|
||||||
@ -133,14 +152,16 @@ func main() {
|
|||||||
return c.JSON(fiber.Map{"version": cfg.DisclaimerVersion, "url": "/legal/DISCLAIMER.md"})
|
return c.JSON(fiber.Map{"version": cfg.DisclaimerVersion, "url": "/legal/DISCLAIMER.md"})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
api := app.Group("/api/v1")
|
||||||
|
|
||||||
// Telegram WebApp auth (публичный)
|
// Telegram WebApp auth (публичный)
|
||||||
if tgH != nil {
|
if tgH != nil {
|
||||||
api.Post("/auth/telegram", tgH.TGAuth)
|
api.Post("/auth/telegram", tgH.TGAuth)
|
||||||
|
api.Post("/telegram/webhook", tgH.BotWebhook)
|
||||||
}
|
}
|
||||||
|
|
||||||
api := app.Group("/api/v1")
|
api.Post("/auth/register", registerLimiter, authH.Register)
|
||||||
api.Post("/auth/register", authH.Register)
|
api.Post("/auth/login", loginLimiter, authH.Login)
|
||||||
api.Post("/auth/login", authH.Login)
|
|
||||||
|
|
||||||
protected := api.Group("", auth.Middleware(authSvc))
|
protected := api.Group("", auth.Middleware(authSvc))
|
||||||
protected.Get("/me", h.GetMe)
|
protected.Get("/me", h.GetMe)
|
||||||
@ -174,6 +195,7 @@ func main() {
|
|||||||
|
|
||||||
// Consents (повторное принятие)
|
// Consents (повторное принятие)
|
||||||
protected.Post("/auth/consents", consentH.Accept)
|
protected.Post("/auth/consents", consentH.Accept)
|
||||||
|
protected.Get("/auth/consents", consentH.Status)
|
||||||
|
|
||||||
// WebSocket — авторизация по ?token=
|
// WebSocket — авторизация по ?token=
|
||||||
app.Get("/ws", ws.AuthMiddleware(authSvc), ws.Upgrade, ws.Handler(hub))
|
app.Get("/ws", ws.AuthMiddleware(authSvc), ws.Upgrade, ws.Handler(hub))
|
||||||
@ -186,8 +208,16 @@ func main() {
|
|||||||
_ = app.ShutdownWithTimeout(10 * time.Second)
|
_ = app.ShutdownWithTimeout(10 * time.Second)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Запуск Telegram-бота (long polling) — если настроен
|
// Запуск Telegram-бота — webhook если доступен, иначе long polling
|
||||||
if bot != nil && tgH != nil {
|
if bot != nil && tgH != nil {
|
||||||
|
webhookURL := cfg.TelegramWebhookURL
|
||||||
|
if webhookURL != "" {
|
||||||
|
if err := bot.SetWebhook(webhookURL, cfg.TelegramWebhookSecret); err != nil {
|
||||||
|
log.Printf("telegram setWebhook failed: %v (fallback to long poll)", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("telegram bot: webhook set to %s", webhookURL)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
go func() {
|
go func() {
|
||||||
log.Println("telegram bot: starting long-poll loop")
|
log.Println("telegram bot: starting long-poll loop")
|
||||||
bot.RunLoop(func(u telegram.Update) {
|
bot.RunLoop(func(u telegram.Update) {
|
||||||
@ -195,6 +225,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", cfg.AppPort)
|
addr := fmt.Sprintf(":%d", cfg.AppPort)
|
||||||
log.Printf("listening on %s", addr)
|
log.Printf("listening on %s", addr)
|
||||||
|
|||||||
1
go.mod
1
go.mod
@ -4,7 +4,6 @@ go 1.22
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gofiber/fiber/v2 v2.52.5
|
github.com/gofiber/fiber/v2 v2.52.5
|
||||||
github.com/gofiber/contrib/jwt v1.0.10
|
|
||||||
github.com/gofiber/contrib/websocket v1.3.0
|
github.com/gofiber/contrib/websocket v1.3.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
|||||||
@ -42,6 +42,8 @@ type Config struct {
|
|||||||
|
|
||||||
// Telegram
|
// Telegram
|
||||||
TelegramBotToken string
|
TelegramBotToken string
|
||||||
|
TelegramWebhookURL string
|
||||||
|
TelegramWebhookSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@ -79,6 +81,8 @@ func Load() (*Config, error) {
|
|||||||
DisclaimerVersion: getEnv("DISCLAIMER_VERSION", "1.0"),
|
DisclaimerVersion: getEnv("DISCLAIMER_VERSION", "1.0"),
|
||||||
|
|
||||||
TelegramBotToken: getEnv("TELEGRAM_BOT_TOKEN", ""),
|
TelegramBotToken: getEnv("TELEGRAM_BOT_TOKEN", ""),
|
||||||
|
TelegramWebhookURL: getEnv("TELEGRAM_WEBHOOK_URL", ""),
|
||||||
|
TelegramWebhookSecret: getEnv("TELEGRAM_WEBHOOK_SECRET", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.JWTSecret == "change-me" {
|
if cfg.JWTSecret == "change-me" {
|
||||||
|
|||||||
@ -49,20 +49,21 @@ type consentsBlock struct {
|
|||||||
var emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
var emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||||
var phoneRe = regexp.MustCompile(`^\+?[0-9]{10,15}$`)
|
var phoneRe = regexp.MustCompile(`^\+?[0-9]{10,15}$`)
|
||||||
|
|
||||||
|
// dummy bcrypt hash для constant-time при user enumeration
|
||||||
|
const dummyHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
|
||||||
|
|
||||||
func (h *AuthHandler) Register(c *fiber.Ctx) error {
|
func (h *AuthHandler) Register(c *fiber.Ctx) error {
|
||||||
var req registerRequest
|
var req registerRequest
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate required consents
|
|
||||||
if !req.Consents.Adult || !req.Consents.Terms || !req.Consents.Privacy || !req.Consents.Disclaimer {
|
if !req.Consents.Adult || !req.Consents.Terms || !req.Consents.Privacy || !req.Consents.Disclaimer {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
"error": "all consents required: adult, terms, privacy, disclaimer",
|
"error": "all consents required: adult, terms, privacy, disclaimer",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalize
|
|
||||||
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||||
req.Phone = strings.TrimSpace(req.Phone)
|
req.Phone = strings.TrimSpace(req.Phone)
|
||||||
req.Name = strings.TrimSpace(req.Name)
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
@ -113,13 +114,12 @@ func (h *AuthHandler) Register(c *fiber.Ctx) error {
|
|||||||
City: req.City,
|
City: req.City,
|
||||||
}
|
}
|
||||||
if err := h.users.CreateWithPassword(c.UserContext(), u, string(hash)); err != nil {
|
if err := h.users.CreateWithPassword(c.UserContext(), u, string(hash)); err != nil {
|
||||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "user already exists or db error"})
|
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "user already exists"})
|
||||||
}
|
}
|
||||||
|
|
||||||
ip := c.IP()
|
ip := c.IP()
|
||||||
ua := c.Get("User-Agent")
|
ua := c.Get("User-Agent")
|
||||||
|
|
||||||
// log consents
|
|
||||||
for _, item := range []struct {
|
for _, item := range []struct {
|
||||||
Type consent.DocType
|
Type consent.DocType
|
||||||
Ver string
|
Ver string
|
||||||
@ -135,7 +135,6 @@ func (h *AuthHandler) Register(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// audit
|
|
||||||
uid := u.ID
|
uid := u.ID
|
||||||
_ = h.audit.Log(c.UserContext(), &audit.Event{
|
_ = h.audit.Log(c.UserContext(), &audit.Event{
|
||||||
UserID: &uid, Action: "user.register",
|
UserID: &uid, Action: "user.register",
|
||||||
@ -187,13 +186,14 @@ func (h *AuthHandler) Login(c *fiber.Ctx) error {
|
|||||||
if req.Email != "" {
|
if req.Email != "" {
|
||||||
u, hash, err = h.users.GetByEmail(c.UserContext(), req.Email)
|
u, hash, err = h.users.GetByEmail(c.UserContext(), req.Email)
|
||||||
} else {
|
} else {
|
||||||
// MVP: поиск по телефону пока не реализован — добавим позже
|
|
||||||
return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "login by phone not implemented yet"})
|
return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "login by phone not implemented yet"})
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
|
||||||
}
|
}
|
||||||
|
// Constant-time: всегда прогоняем bcrypt даже если юзер не найден — против timing-атак
|
||||||
if u == nil {
|
if u == nil {
|
||||||
|
_ = bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(req.Password))
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
|
||||||
}
|
}
|
||||||
if u.IsBlocked {
|
if u.IsBlocked {
|
||||||
|
|||||||
@ -64,3 +64,23 @@ func (h *ConsentHandlers) Accept(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
return c.JSON(fiber.Map{"ok": true, "accepted_at": time.Now()})
|
return c.JSON(fiber.Map{"ok": true, "accepted_at": time.Now()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status — проверка, какие согласия у пользователя приняты
|
||||||
|
func (h *ConsentHandlers) Status(c *fiber.Ctx) error {
|
||||||
|
me, err := userID(c)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
|
||||||
|
}
|
||||||
|
accepted := fiber.Map{}
|
||||||
|
for _, docType := range []consent.DocType{consent.DocTerms, consent.DocPrivacy, consent.DocDisclaimer, consent.DocAdult} {
|
||||||
|
ok, _ := h.Consent.HasAccepted(c.UserContext(), me, docType, "1.0")
|
||||||
|
if ok {
|
||||||
|
accepted[string(docType)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allOk := len(accepted) == 4
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"all_accepted": allOk,
|
||||||
|
"accepted": accepted,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,13 +1,10 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/google/uuid"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
|
|
||||||
"github.com/buhapp/backend/internal/audit"
|
"github.com/buhapp/backend/internal/audit"
|
||||||
"github.com/buhapp/backend/internal/auth"
|
"github.com/buhapp/backend/internal/auth"
|
||||||
@ -23,11 +20,11 @@ type TGHandler struct {
|
|||||||
Auth *auth.Service
|
Auth *auth.Service
|
||||||
Consent *consent.Repo
|
Consent *consent.Repo
|
||||||
Audit *audit.Repo
|
Audit *audit.Repo
|
||||||
JWTSecret []byte
|
WebhookSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTGHandler(bot *telegram.Bot, u *users.Repo, a *auth.Service, c *consent.Repo, au *audit.Repo, secret []byte) *TGHandler {
|
func NewTGHandler(bot *telegram.Bot, u *users.Repo, a *auth.Service, c *consent.Repo, au *audit.Repo, webhookSecret string) *TGHandler {
|
||||||
return &TGHandler{Bot: bot, Users: u, Auth: a, Consent: c, Audit: au, JWTSecret: secret}
|
return &TGHandler{Bot: bot, Users: u, Auth: a, Consent: c, Audit: au, WebhookSecret: webhookSecret}
|
||||||
}
|
}
|
||||||
|
|
||||||
type tgAuthRequest struct {
|
type tgAuthRequest struct {
|
||||||
@ -73,7 +70,8 @@ func (h *TGHandler) TGAuth(c *fiber.Ctx) error {
|
|||||||
u = &users.User{
|
u = &users.User{
|
||||||
ID: uid,
|
ID: uid,
|
||||||
Name: name,
|
Name: name,
|
||||||
IsVerified: false,
|
TgUsername: tgData.User.Username,
|
||||||
|
IsVerified: true, // подтверждён через Telegram
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
@ -108,11 +106,17 @@ func (h *TGHandler) TGAuth(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Webhook для бота — если будешь использовать webhook
|
// BotWebhook — webhook от Telegram. Проверяем Secret Token.
|
||||||
func (h *TGHandler) BotWebhook(c *fiber.Ctx) error {
|
func (h *TGHandler) BotWebhook(c *fiber.Ctx) error {
|
||||||
|
// Проверка секрета (если задан)
|
||||||
|
if h.WebhookSecret != "" {
|
||||||
|
if c.Get("X-Telegram-Bot-Api-Secret-Token") != h.WebhookSecret {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid secret"})
|
||||||
|
}
|
||||||
|
}
|
||||||
var u telegram.Update
|
var u telegram.Update
|
||||||
if err := c.BodyParser(&u); err != nil {
|
if err := c.BodyParser(&u); err != nil {
|
||||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid"})
|
||||||
}
|
}
|
||||||
h.handleUpdate(c.UserContext(), u)
|
h.handleUpdate(c.UserContext(), u)
|
||||||
return c.JSON(fiber.Map{"ok": true})
|
return c.JSON(fiber.Map{"ok": true})
|
||||||
@ -146,9 +150,9 @@ func (h *TGHandler) handleMessage(m *telegram.Message) {
|
|||||||
"Привет"+ifNonEmpty(name, ", "+name)+"!\n\nBuhApp — приложение для поиска компании.\n\nНажми кнопку ниже, чтобы открыть:",
|
"Привет"+ifNonEmpty(name, ", "+name)+"!\n\nBuhApp — приложение для поиска компании.\n\nНажми кнопку ниже, чтобы открыть:",
|
||||||
func(opts *telegram.MessageOptions) {
|
func(opts *telegram.MessageOptions) {
|
||||||
opts.ParseMode = "HTML"
|
opts.ParseMode = "HTML"
|
||||||
opts.ReplyMarkup = telegram.InlineKeyboard([][]telegram.InlineButton{{
|
opts.ReplyMarkup = telegram.InlineKeyboard([]telegram.InlineButton{
|
||||||
{Text: "🚀 Открыть BuhApp", WebApp: &telegram.WebAppInfo{URL: "https://app.buhapp.mygoodservice.ru"}},
|
{Text: "🚀 Открыть BuhApp", WebApp: &telegram.WebAppInfo{URL: "https://buhapp.mygoodservice.ru"}},
|
||||||
}})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -164,7 +168,3 @@ func ifNonEmpty(s, prefix string) string {
|
|||||||
}
|
}
|
||||||
return prefix
|
return prefix
|
||||||
}
|
}
|
||||||
|
|
||||||
// bcrypt не используется здесь, оставлю для импорта
|
|
||||||
var _ = bcrypt.MinCost
|
|
||||||
var _ = uuid.Nil
|
|
||||||
@ -16,6 +16,7 @@ type Bot struct {
|
|||||||
Token string
|
Token string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
hc *http.Client
|
hc *http.Client
|
||||||
|
WebhookURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBot(token string) *Bot {
|
func NewBot(token string) *Bot {
|
||||||
@ -26,6 +27,20 @@ func NewBot(token string) *Bot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetWebhook — регистрирует webhook URL у Telegram
|
||||||
|
func (b *Bot) SetWebhook(url string, secret string) error {
|
||||||
|
type setWebhookReq struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
SecretToken string `json:"secret_token,omitempty"`
|
||||||
|
AllowedUpdates []string `json:"allowed_updates,omitempty"`
|
||||||
|
}
|
||||||
|
return b.call("setWebhook", setWebhookReq{
|
||||||
|
URL: url,
|
||||||
|
SecretToken: secret,
|
||||||
|
AllowedUpdates: []string{"message", "callback_query"},
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
type Update struct {
|
type Update struct {
|
||||||
UpdateID int64 `json:"update_id"`
|
UpdateID int64 `json:"update_id"`
|
||||||
Message *Message `json:"message,omitempty"`
|
Message *Message `json:"message,omitempty"`
|
||||||
|
|||||||
@ -14,7 +14,8 @@ type User struct {
|
|||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
Phone string `json:"phone,omitempty"`
|
Phone string `json:"phone,omitempty"`
|
||||||
TelegramID *int64 `json:"telegram_id,omitempty"`
|
TgID *int64 `json:"telegram_id,omitempty"`
|
||||||
|
TgUsername string `json:"tg_username,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Birthdate *time.Time `json:"birthdate,omitempty"`
|
Birthdate *time.Time `json:"birthdate,omitempty"`
|
||||||
Gender string `json:"gender"`
|
Gender string `json:"gender"`
|
||||||
@ -26,6 +27,8 @@ type User struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||||
|
RatingAvg float64 `json:"rating_avg,omitempty"`
|
||||||
|
RatingCount int `json:"rating_count,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Repo struct {
|
type Repo struct {
|
||||||
@ -56,12 +59,14 @@ func (r *Repo) UpdateRaw(ctx context.Context, sql string, args ...interface{}) (
|
|||||||
// CreateWithTelegramID — создать пользователя с привязкой к Telegram
|
// CreateWithTelegramID — создать пользователя с привязкой к Telegram
|
||||||
func (r *Repo) CreateWithTelegramID(ctx context.Context, u *User, tgID int64) error {
|
func (r *Repo) CreateWithTelegramID(ctx context.Context, u *User, tgID int64) error {
|
||||||
return r.pool.QueryRow(ctx, `
|
return r.pool.QueryRow(ctx, `
|
||||||
INSERT INTO users (id, telegram_id, name, password_hash)
|
INSERT INTO users (id, telegram_id, tg_username, name, password_hash, is_verified)
|
||||||
VALUES ($1, $2, $3, '')
|
VALUES ($1, $2, NULLIF($3, ''), $4, '', $5)
|
||||||
ON CONFLICT (telegram_id) DO UPDATE
|
ON CONFLICT (telegram_id) DO UPDATE
|
||||||
SET name = EXCLUDED.name, updated_at = NOW()
|
SET name = EXCLUDED.name,
|
||||||
|
tg_username = EXCLUDED.tg_username,
|
||||||
|
updated_at = NOW()
|
||||||
RETURNING id, created_at, updated_at`,
|
RETURNING id, created_at, updated_at`,
|
||||||
u.ID, tgID, u.Name,
|
u.ID, tgID, u.TgUsername, u.Name, u.IsVerified,
|
||||||
).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
|
).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,7 +89,7 @@ func (r *Repo) GetByTelegramID(ctx context.Context, tgID int64) (*User, error) {
|
|||||||
}
|
}
|
||||||
if email != nil { u.Email = *email }
|
if email != nil { u.Email = *email }
|
||||||
if phone != nil { u.Phone = *phone }
|
if phone != nil { u.Phone = *phone }
|
||||||
if telegramID != nil { u.TelegramID = telegramID }
|
if telegramID != nil { u.TgID = telegramID }
|
||||||
if gender != nil { u.Gender = *gender }
|
if gender != nil { u.Gender = *gender }
|
||||||
if city != nil { u.City = *city }
|
if city != nil { u.City = *city }
|
||||||
if bio != nil { u.Bio = *bio }
|
if bio != nil { u.Bio = *bio }
|
||||||
|
|||||||
BIN
tests/__pycache__/conftest.cpython-311-pytest-9.1.1.pyc
Normal file
BIN
tests/__pycache__/conftest.cpython-311-pytest-9.1.1.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_api.cpython-311-pytest-9.1.1.pyc
Normal file
BIN
tests/__pycache__/test_api.cpython-311-pytest-9.1.1.pyc
Normal file
Binary file not shown.
102
tests/conftest.py
Normal file
102
tests/conftest.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
"""Shared fixtures for BuhApp backend API tests.
|
||||||
|
|
||||||
|
Base URL is overridable via the BUHAPP_API env var. Tests register fresh
|
||||||
|
users on each session — they're not cleaned up, which matches the API's
|
||||||
|
behaviour (duplicate emails return 409).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
BASE_URL = os.environ.get("BUHAPP_API", "https://api.buhapp.mygoodservice.ru").rstrip("/")
|
||||||
|
TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- helpers -----------------------------
|
||||||
|
|
||||||
|
def _rand_email() -> str:
|
||||||
|
"""Random, unique-looking email that is unlikely to collide with real users."""
|
||||||
|
suffix = "".join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(10))
|
||||||
|
return f"qa-{suffix}-{int(time.time() * 1000)}@buhapp-test.local"
|
||||||
|
|
||||||
|
|
||||||
|
def _rand_name(prefix: str = "QA") -> str:
|
||||||
|
return f"{prefix} {secrets.token_hex(3)}"
|
||||||
|
|
||||||
|
|
||||||
|
def consents_block() -> Dict[str, bool]:
|
||||||
|
return {"adult": True, "terms": True, "privacy": True, "disclaimer": True}
|
||||||
|
|
||||||
|
|
||||||
|
def register_user(name_prefix: str = "QA") -> Dict[str, Any]:
|
||||||
|
"""Register a fresh user and return {"user": {...}, "tokens": {...}}."""
|
||||||
|
email = _rand_email()
|
||||||
|
payload = {
|
||||||
|
"email": email,
|
||||||
|
"password": "TestPass123!",
|
||||||
|
"name": _rand_name(name_prefix),
|
||||||
|
"city": "Москва",
|
||||||
|
"gender": "m",
|
||||||
|
"birthdate": "1995-06-15",
|
||||||
|
"consents": consents_block(),
|
||||||
|
}
|
||||||
|
r = requests.post(f"{BASE_URL}/api/v1/auth/register", json=payload, timeout=TIMEOUT)
|
||||||
|
assert r.status_code == 201, f"register failed: {r.status_code} {r.text}"
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------- fixtures -----------------------------
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def base_url() -> str:
|
||||||
|
return BASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def api_alive(base_url: str) -> bool:
|
||||||
|
r = requests.get(f"{base_url}/health", timeout=TIMEOUT)
|
||||||
|
return r.status_code == 200 and r.json().get("status") == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def new_user() -> Dict[str, Any]:
|
||||||
|
"""Freshly registered user with tokens."""
|
||||||
|
return register_user("UserA")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def second_user() -> Dict[str, Any]:
|
||||||
|
"""A second freshly registered user, used for chat/block/review flows."""
|
||||||
|
return register_user("UserB")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers(new_user: Dict[str, Any]) -> Dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {new_user['tokens']['Access']}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def second_auth_headers(second_user: Dict[str, Any]) -> Dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {second_user['tokens']['Access']}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def chat_between(new_user, second_user, auth_headers, second_auth_headers):
|
||||||
|
"""Returns (chat_id, other_user_id) where auth_headers owns the chat with second_user."""
|
||||||
|
r = requests.post(
|
||||||
|
f"{BASE_URL}/api/v1/chats",
|
||||||
|
json={"other_id": second_user["user"]["id"]},
|
||||||
|
headers=auth_headers,
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, f"ensure chat failed: {r.status_code} {r.text}"
|
||||||
|
return r.json()["id"], second_user["user"]["id"]
|
||||||
1156
tests/test_api.py
Normal file
1156
tests/test_api.py
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user