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/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/limiter"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
|
||||
@ -84,7 +85,7 @@ func main() {
|
||||
var tgH *handlers.TGHandler
|
||||
if 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")
|
||||
} else {
|
||||
log.Println("telegram bot: NOT configured (set TELEGRAM_BOT_TOKEN)")
|
||||
@ -107,16 +108,34 @@ func main() {
|
||||
app.Use(recover.New())
|
||||
app.Use(logger.New())
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*",
|
||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
||||
AllowOrigins: "https://buhapp.mygoodservice.ru,https://t.me,https://web.telegram.org",
|
||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization, X-Telegram-Bot-Api-Secret-Token",
|
||||
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 {
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "ok",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"db": pg.Pool != nil,
|
||||
"redis": rdb != nil,
|
||||
"storage": st != nil,
|
||||
@ -133,14 +152,16 @@ func main() {
|
||||
return c.JSON(fiber.Map{"version": cfg.DisclaimerVersion, "url": "/legal/DISCLAIMER.md"})
|
||||
})
|
||||
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
// Telegram WebApp auth (публичный)
|
||||
if tgH != nil {
|
||||
api.Post("/auth/telegram", tgH.TGAuth)
|
||||
api.Post("/telegram/webhook", tgH.BotWebhook)
|
||||
}
|
||||
|
||||
api := app.Group("/api/v1")
|
||||
api.Post("/auth/register", authH.Register)
|
||||
api.Post("/auth/login", authH.Login)
|
||||
api.Post("/auth/register", registerLimiter, authH.Register)
|
||||
api.Post("/auth/login", loginLimiter, authH.Login)
|
||||
|
||||
protected := api.Group("", auth.Middleware(authSvc))
|
||||
protected.Get("/me", h.GetMe)
|
||||
@ -174,6 +195,7 @@ func main() {
|
||||
|
||||
// Consents (повторное принятие)
|
||||
protected.Post("/auth/consents", consentH.Accept)
|
||||
protected.Get("/auth/consents", consentH.Status)
|
||||
|
||||
// WebSocket — авторизация по ?token=
|
||||
app.Get("/ws", ws.AuthMiddleware(authSvc), ws.Upgrade, ws.Handler(hub))
|
||||
@ -186,14 +208,23 @@ func main() {
|
||||
_ = app.ShutdownWithTimeout(10 * time.Second)
|
||||
}()
|
||||
|
||||
// Запуск Telegram-бота (long polling) — если настроен
|
||||
// Запуск Telegram-бота — webhook если доступен, иначе long polling
|
||||
if bot != nil && tgH != nil {
|
||||
go func() {
|
||||
log.Println("telegram bot: starting long-poll loop")
|
||||
bot.RunLoop(func(u telegram.Update) {
|
||||
tgH.ProcessUpdate(u)
|
||||
})
|
||||
}()
|
||||
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() {
|
||||
log.Println("telegram bot: starting long-poll loop")
|
||||
bot.RunLoop(func(u telegram.Update) {
|
||||
tgH.ProcessUpdate(u)
|
||||
})
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.AppPort)
|
||||
|
||||
1
go.mod
1
go.mod
@ -4,7 +4,6 @@ go 1.22
|
||||
|
||||
require (
|
||||
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/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
|
||||
@ -41,7 +41,9 @@ type Config struct {
|
||||
DisclaimerVersion string
|
||||
|
||||
// Telegram
|
||||
TelegramBotToken string
|
||||
TelegramBotToken string
|
||||
TelegramWebhookURL string
|
||||
TelegramWebhookSecret string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
@ -78,7 +80,9 @@ func Load() (*Config, error) {
|
||||
PrivacyVersion: getEnv("PRIVACY_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" {
|
||||
|
||||
@ -29,40 +29,41 @@ func NewAuthHandler(cfg *config.Config, u *users.Repo, c *consent.Repo, a *audit
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Birthdate string `json:"birthdate"`
|
||||
Gender string `json:"gender"`
|
||||
City string `json:"city"`
|
||||
Consents consentsBlock `json:"consents"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Birthdate string `json:"birthdate"`
|
||||
Gender string `json:"gender"`
|
||||
City string `json:"city"`
|
||||
Consents consentsBlock `json:"consents"`
|
||||
}
|
||||
|
||||
type consentsBlock struct {
|
||||
Adult bool `json:"adult"`
|
||||
Terms bool `json:"terms"`
|
||||
Privacy bool `json:"privacy"`
|
||||
Disclaimer bool `json:"disclaimer"`
|
||||
Adult bool `json:"adult"`
|
||||
Terms bool `json:"terms"`
|
||||
Privacy bool `json:"privacy"`
|
||||
Disclaimer bool `json:"disclaimer"`
|
||||
}
|
||||
|
||||
var emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
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 {
|
||||
var req registerRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
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 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "all consents required: adult, terms, privacy, disclaimer",
|
||||
})
|
||||
}
|
||||
|
||||
// normalize
|
||||
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||
req.Phone = strings.TrimSpace(req.Phone)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
@ -113,13 +114,12 @@ func (h *AuthHandler) Register(c *fiber.Ctx) error {
|
||||
City: req.City,
|
||||
}
|
||||
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()
|
||||
ua := c.Get("User-Agent")
|
||||
|
||||
// log consents
|
||||
for _, item := range []struct {
|
||||
Type consent.DocType
|
||||
Ver string
|
||||
@ -135,7 +135,6 @@ func (h *AuthHandler) Register(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// audit
|
||||
uid := u.ID
|
||||
_ = h.audit.Log(c.UserContext(), &audit.Event{
|
||||
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{
|
||||
"user": fiber.Map{
|
||||
"id": u.ID,
|
||||
"email": u.Email,
|
||||
"phone": u.Phone,
|
||||
"name": u.Name,
|
||||
"city": u.City,
|
||||
"gender": u.Gender,
|
||||
"id": u.ID,
|
||||
"email": u.Email,
|
||||
"phone": u.Phone,
|
||||
"name": u.Name,
|
||||
"city": u.City,
|
||||
"gender": u.Gender,
|
||||
"birthdate": u.Birthdate,
|
||||
},
|
||||
"tokens": tokens,
|
||||
@ -187,13 +186,14 @@ func (h *AuthHandler) Login(c *fiber.Ctx) error {
|
||||
if req.Email != "" {
|
||||
u, hash, err = h.users.GetByEmail(c.UserContext(), req.Email)
|
||||
} else {
|
||||
// MVP: поиск по телефону пока не реализован — добавим позже
|
||||
return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "login by phone not implemented yet"})
|
||||
}
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
|
||||
}
|
||||
// Constant-time: всегда прогоняем bcrypt даже если юзер не найден — против timing-атак
|
||||
if u == nil {
|
||||
_ = bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(req.Password))
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
|
||||
}
|
||||
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()})
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/auth"
|
||||
@ -18,16 +15,16 @@ import (
|
||||
|
||||
// TGHandler — обработчики для Telegram WebApp
|
||||
type TGHandler struct {
|
||||
Bot *telegram.Bot
|
||||
Users *users.Repo
|
||||
Auth *auth.Service
|
||||
Consent *consent.Repo
|
||||
Audit *audit.Repo
|
||||
JWTSecret []byte
|
||||
Bot *telegram.Bot
|
||||
Users *users.Repo
|
||||
Auth *auth.Service
|
||||
Consent *consent.Repo
|
||||
Audit *audit.Repo
|
||||
WebhookSecret string
|
||||
}
|
||||
|
||||
func NewTGHandler(bot *telegram.Bot, u *users.Repo, a *auth.Service, c *consent.Repo, au *audit.Repo, secret []byte) *TGHandler {
|
||||
return &TGHandler{Bot: bot, Users: u, Auth: a, Consent: c, Audit: au, JWTSecret: secret}
|
||||
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, WebhookSecret: webhookSecret}
|
||||
}
|
||||
|
||||
type tgAuthRequest struct {
|
||||
@ -71,11 +68,12 @@ func (h *TGHandler) TGAuth(c *fiber.Ctx) error {
|
||||
name = "User"
|
||||
}
|
||||
u = &users.User{
|
||||
ID: uid,
|
||||
Name: name,
|
||||
IsVerified: false,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
ID: uid,
|
||||
Name: name,
|
||||
TgUsername: tgData.User.Username,
|
||||
IsVerified: true, // подтверждён через Telegram
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
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()})
|
||||
@ -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 {
|
||||
// Проверка секрета (если задан)
|
||||
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
|
||||
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)
|
||||
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Нажми кнопку ниже, чтобы открыть:",
|
||||
func(opts *telegram.MessageOptions) {
|
||||
opts.ParseMode = "HTML"
|
||||
opts.ReplyMarkup = telegram.InlineKeyboard([][]telegram.InlineButton{{
|
||||
{Text: "🚀 Открыть BuhApp", WebApp: &telegram.WebAppInfo{URL: "https://app.buhapp.mygoodservice.ru"}},
|
||||
}})
|
||||
opts.ReplyMarkup = telegram.InlineKeyboard([]telegram.InlineButton{
|
||||
{Text: "🚀 Открыть BuhApp", WebApp: &telegram.WebAppInfo{URL: "https://buhapp.mygoodservice.ru"}},
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
@ -164,7 +168,3 @@ func ifNonEmpty(s, prefix string) string {
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
// bcrypt не используется здесь, оставлю для импорта
|
||||
var _ = bcrypt.MinCost
|
||||
var _ = uuid.Nil
|
||||
@ -13,9 +13,10 @@ import (
|
||||
const apiBase = "https://api.telegram.org/bot"
|
||||
|
||||
type Bot struct {
|
||||
Token string
|
||||
BaseURL string
|
||||
hc *http.Client
|
||||
Token string
|
||||
BaseURL string
|
||||
hc *http.Client
|
||||
WebhookURL string
|
||||
}
|
||||
|
||||
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 {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message *Message `json:"message,omitempty"`
|
||||
|
||||
@ -14,7 +14,8 @@ type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email,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"`
|
||||
Birthdate *time.Time `json:"birthdate,omitempty"`
|
||||
Gender string `json:"gender"`
|
||||
@ -26,6 +27,8 @@ type User struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||
RatingAvg float64 `json:"rating_avg,omitempty"`
|
||||
RatingCount int `json:"rating_count,omitempty"`
|
||||
}
|
||||
|
||||
type Repo struct {
|
||||
@ -56,12 +59,14 @@ func (r *Repo) UpdateRaw(ctx context.Context, sql string, args ...interface{}) (
|
||||
// CreateWithTelegramID — создать пользователя с привязкой к Telegram
|
||||
func (r *Repo) CreateWithTelegramID(ctx context.Context, u *User, tgID int64) error {
|
||||
return r.pool.QueryRow(ctx, `
|
||||
INSERT INTO users (id, telegram_id, name, password_hash)
|
||||
VALUES ($1, $2, $3, '')
|
||||
INSERT INTO users (id, telegram_id, tg_username, name, password_hash, is_verified)
|
||||
VALUES ($1, $2, NULLIF($3, ''), $4, '', $5)
|
||||
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`,
|
||||
u.ID, tgID, u.Name,
|
||||
u.ID, tgID, u.TgUsername, u.Name, u.IsVerified,
|
||||
).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 phone != nil { u.Phone = *phone }
|
||||
if telegramID != nil { u.TelegramID = telegramID }
|
||||
if telegramID != nil { u.TgID = telegramID }
|
||||
if gender != nil { u.Gender = *gender }
|
||||
if city != nil { u.City = *city }
|
||||
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