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:
ga 2026-08-20 20:42:22 +00:00
parent c6d219c51f
commit c30faf02ef
12 changed files with 1407 additions and 75 deletions

View File

@ -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,14 +208,23 @@ 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 {
go func() { webhookURL := cfg.TelegramWebhookURL
log.Println("telegram bot: starting long-poll loop") if webhookURL != "" {
bot.RunLoop(func(u telegram.Update) { if err := bot.SetWebhook(webhookURL, cfg.TelegramWebhookSecret); err != nil {
tgH.ProcessUpdate(u) log.Printf("telegram setWebhook failed: %v (fallback to long poll)", err)
}) } else {
}() log.Printf("telegram bot: webhook set to %s", webhookURL)
}
} else {
go func() {
log.Println("telegram bot: starting long-poll loop")
bot.RunLoop(func(u telegram.Update) {
tgH.ProcessUpdate(u)
})
}()
}
} }
addr := fmt.Sprintf(":%d", cfg.AppPort) addr := fmt.Sprintf(":%d", cfg.AppPort)

1
go.mod
View File

@ -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

View File

@ -41,7 +41,9 @@ type Config struct {
DisclaimerVersion string DisclaimerVersion string
// Telegram // Telegram
TelegramBotToken string TelegramBotToken string
TelegramWebhookURL string
TelegramWebhookSecret string
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@ -78,7 +80,9 @@ func Load() (*Config, error) {
PrivacyVersion: getEnv("PRIVACY_VERSION", "1.0"), PrivacyVersion: getEnv("PRIVACY_VERSION", "1.0"),
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" {

View File

@ -29,40 +29,41 @@ func NewAuthHandler(cfg *config.Config, u *users.Repo, c *consent.Repo, a *audit
} }
type registerRequest struct { type registerRequest struct {
Email string `json:"email"` Email string `json:"email"`
Phone string `json:"phone"` Phone string `json:"phone"`
Password string `json:"password"` Password string `json:"password"`
Name string `json:"name"` Name string `json:"name"`
Birthdate string `json:"birthdate"` Birthdate string `json:"birthdate"`
Gender string `json:"gender"` Gender string `json:"gender"`
City string `json:"city"` City string `json:"city"`
Consents consentsBlock `json:"consents"` Consents consentsBlock `json:"consents"`
} }
type consentsBlock struct { type consentsBlock struct {
Adult bool `json:"adult"` Adult bool `json:"adult"`
Terms bool `json:"terms"` Terms bool `json:"terms"`
Privacy bool `json:"privacy"` Privacy bool `json:"privacy"`
Disclaimer bool `json:"disclaimer"` Disclaimer bool `json:"disclaimer"`
} }
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",
@ -149,12 +148,12 @@ func (h *AuthHandler) Register(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(fiber.Map{ return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"user": fiber.Map{ "user": fiber.Map{
"id": u.ID, "id": u.ID,
"email": u.Email, "email": u.Email,
"phone": u.Phone, "phone": u.Phone,
"name": u.Name, "name": u.Name,
"city": u.City, "city": u.City,
"gender": u.Gender, "gender": u.Gender,
"birthdate": u.Birthdate, "birthdate": u.Birthdate,
}, },
"tokens": tokens, "tokens": tokens,
@ -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 {

View File

@ -63,4 +63,24 @@ func (h *ConsentHandlers) Accept(c *fiber.Ctx) error {
IP: c.IP(), UserAgent: c.Get("User-Agent"), IP: c.IP(), UserAgent: c.Get("User-Agent"),
}) })
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,
})
} }

View File

@ -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"
@ -18,16 +15,16 @@ import (
// TGHandler — обработчики для Telegram WebApp // TGHandler — обработчики для Telegram WebApp
type TGHandler struct { type TGHandler struct {
Bot *telegram.Bot Bot *telegram.Bot
Users *users.Repo Users *users.Repo
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 {
@ -71,11 +68,12 @@ func (h *TGHandler) TGAuth(c *fiber.Ctx) error {
name = "User" name = "User"
} }
u = &users.User{ u = &users.User{
ID: uid, ID: uid,
Name: name, Name: name,
IsVerified: false, TgUsername: tgData.User.Username,
CreatedAt: time.Now(), IsVerified: true, // подтверждён через Telegram
UpdatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(),
} }
if err := h.Users.CreateWithTelegramID(c.UserContext(), u, tgID); err != nil { if err := h.Users.CreateWithTelegramID(c.UserContext(), u, tgID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "create user failed: " + err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "create user failed: " + err.Error()})
@ -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

View File

@ -13,9 +13,10 @@ import (
const apiBase = "https://api.telegram.org/bot" const apiBase = "https://api.telegram.org/bot"
type Bot struct { 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"`

View File

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

102
tests/conftest.py Normal file
View 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

File diff suppressed because it is too large Load Diff