Merge branch 'BATON-008-backend_dev'
This commit is contained in:
commit
42f4251184
11 changed files with 651 additions and 4 deletions
1
=2.0.0
Normal file
1
=2.0.0
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
(eval):1: command not found: pip
|
||||||
|
|
@ -22,3 +22,7 @@ WEBHOOK_ENABLED: bool = os.getenv("WEBHOOK_ENABLED", "true").lower() == "true"
|
||||||
FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000")
|
FRONTEND_ORIGIN: str = os.getenv("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||||
APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping
|
APP_URL: str | None = os.getenv("APP_URL") # Публичный URL приложения для keep-alive self-ping
|
||||||
ADMIN_TOKEN: str = _require("ADMIN_TOKEN")
|
ADMIN_TOKEN: str = _require("ADMIN_TOKEN")
|
||||||
|
ADMIN_CHAT_ID: str = os.getenv("ADMIN_CHAT_ID", "5694335584")
|
||||||
|
VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "")
|
||||||
|
VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "")
|
||||||
|
VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "")
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,23 @@ async def init_db() -> None:
|
||||||
count INTEGER NOT NULL DEFAULT 0,
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
window_start REAL NOT NULL DEFAULT 0
|
window_start REAL NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS registrations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
login TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
push_subscription TEXT DEFAULT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_registrations_status
|
||||||
|
ON registrations(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_registrations_email
|
||||||
|
ON registrations(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_registrations_login
|
||||||
|
ON registrations(login);
|
||||||
""")
|
""")
|
||||||
# Migrations for existing databases (silently ignore if columns already exist)
|
# Migrations for existing databases (silently ignore if columns already exist)
|
||||||
for stmt in [
|
for stmt in [
|
||||||
|
|
@ -284,6 +301,56 @@ async def rate_limit_increment(key: str, window: float) -> int:
|
||||||
return row["count"] if row else 1
|
return row["count"] if row else 1
|
||||||
|
|
||||||
|
|
||||||
|
async def create_registration(
|
||||||
|
email: str,
|
||||||
|
login: str,
|
||||||
|
password_hash: str,
|
||||||
|
push_subscription: Optional[str] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Insert a new registration. Raises aiosqlite.IntegrityError on email/login conflict."""
|
||||||
|
async with _get_conn() as conn:
|
||||||
|
async with conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO registrations (email, login, password_hash, push_subscription)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(email, login, password_hash, push_subscription),
|
||||||
|
) as cur:
|
||||||
|
reg_id = cur.lastrowid
|
||||||
|
await conn.commit()
|
||||||
|
return reg_id # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_registration(reg_id: int) -> Optional[dict]:
|
||||||
|
async with _get_conn() as conn:
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT id, email, login, status, push_subscription, created_at FROM registrations WHERE id = ?",
|
||||||
|
(reg_id,),
|
||||||
|
) as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"email": row["email"],
|
||||||
|
"login": row["login"],
|
||||||
|
"status": row["status"],
|
||||||
|
"push_subscription": row["push_subscription"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_registration_status(reg_id: int, status: str) -> bool:
|
||||||
|
async with _get_conn() as conn:
|
||||||
|
async with conn.execute(
|
||||||
|
"UPDATE registrations SET status = ? WHERE id = ?",
|
||||||
|
(status, reg_id),
|
||||||
|
) as cur:
|
||||||
|
changed = cur.rowcount > 0
|
||||||
|
await conn.commit()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
async def save_telegram_batch(
|
async def save_telegram_batch(
|
||||||
message_text: str,
|
message_text: str,
|
||||||
signals_count: int,
|
signals_count: int,
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,14 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
from backend import config, db, telegram
|
from backend import config, db, push, telegram
|
||||||
from backend.middleware import rate_limit_register, rate_limit_signal, verify_admin_token, verify_webhook_secret
|
from backend.middleware import rate_limit_auth_register, rate_limit_register, rate_limit_signal, verify_admin_token, verify_webhook_secret
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
AdminBlockRequest,
|
AdminBlockRequest,
|
||||||
AdminCreateUserRequest,
|
AdminCreateUserRequest,
|
||||||
AdminSetPasswordRequest,
|
AdminSetPasswordRequest,
|
||||||
|
AuthRegisterRequest,
|
||||||
|
AuthRegisterResponse,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
SignalRequest,
|
SignalRequest,
|
||||||
|
|
@ -183,6 +185,84 @@ async def signal(
|
||||||
return SignalResponse(status="ok", signal_id=signal_id)
|
return SignalResponse(status="ok", signal_id=signal_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/register", response_model=AuthRegisterResponse, status_code=201)
|
||||||
|
async def auth_register(
|
||||||
|
body: AuthRegisterRequest,
|
||||||
|
_: None = Depends(rate_limit_auth_register),
|
||||||
|
) -> AuthRegisterResponse:
|
||||||
|
password_hash = _hash_password(body.password)
|
||||||
|
push_sub_json = (
|
||||||
|
body.push_subscription.model_dump_json() if body.push_subscription else None
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
reg_id = await db.create_registration(
|
||||||
|
email=str(body.email),
|
||||||
|
login=body.login,
|
||||||
|
password_hash=password_hash,
|
||||||
|
push_subscription=push_sub_json,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# aiosqlite.IntegrityError on email/login UNIQUE conflict
|
||||||
|
if "UNIQUE" in str(exc) or "unique" in str(exc).lower():
|
||||||
|
raise HTTPException(status_code=409, detail="Email или логин уже существует")
|
||||||
|
raise
|
||||||
|
reg = await db.get_registration(reg_id)
|
||||||
|
asyncio.create_task(
|
||||||
|
telegram.send_registration_notification(
|
||||||
|
reg_id=reg_id,
|
||||||
|
login=body.login,
|
||||||
|
email=str(body.email),
|
||||||
|
created_at=reg["created_at"] if reg else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return AuthRegisterResponse(status="pending", message="Заявка отправлена на рассмотрение")
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_callback_query(cb: dict) -> None:
|
||||||
|
"""Process approve/reject callback from admin Telegram inline buttons."""
|
||||||
|
data = cb.get("data", "")
|
||||||
|
callback_query_id = cb.get("id", "")
|
||||||
|
message = cb.get("message", {})
|
||||||
|
chat_id = message.get("chat", {}).get("id")
|
||||||
|
message_id = message.get("message_id")
|
||||||
|
|
||||||
|
if ":" not in data:
|
||||||
|
return
|
||||||
|
action, reg_id_str = data.split(":", 1)
|
||||||
|
try:
|
||||||
|
reg_id = int(reg_id_str)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
reg = await db.get_registration(reg_id)
|
||||||
|
if reg is None:
|
||||||
|
await telegram.answer_callback_query(callback_query_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == "approve":
|
||||||
|
await db.update_registration_status(reg_id, "approved")
|
||||||
|
if chat_id and message_id:
|
||||||
|
await telegram.edit_message_text(
|
||||||
|
chat_id, message_id, f"✅ Пользователь {reg['login']} одобрен"
|
||||||
|
)
|
||||||
|
if reg["push_subscription"]:
|
||||||
|
asyncio.create_task(
|
||||||
|
push.send_push(
|
||||||
|
reg["push_subscription"],
|
||||||
|
"Baton",
|
||||||
|
"Ваша регистрация одобрена!",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif action == "reject":
|
||||||
|
await db.update_registration_status(reg_id, "rejected")
|
||||||
|
if chat_id and message_id:
|
||||||
|
await telegram.edit_message_text(
|
||||||
|
chat_id, message_id, f"❌ Пользователь {reg['login']} отклонён"
|
||||||
|
)
|
||||||
|
|
||||||
|
await telegram.answer_callback_query(callback_query_id)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/users", dependencies=[Depends(verify_admin_token)])
|
@app.get("/admin/users", dependencies=[Depends(verify_admin_token)])
|
||||||
async def admin_list_users() -> list[dict]:
|
async def admin_list_users() -> list[dict]:
|
||||||
return await db.admin_list_users()
|
return await db.admin_list_users()
|
||||||
|
|
@ -227,6 +307,13 @@ async def webhook_telegram(
|
||||||
_: None = Depends(verify_webhook_secret),
|
_: None = Depends(verify_webhook_secret),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
update = await request.json()
|
update = await request.json()
|
||||||
|
|
||||||
|
# Handle inline button callback queries (approve/reject registration)
|
||||||
|
callback_query = update.get("callback_query")
|
||||||
|
if callback_query:
|
||||||
|
await _handle_callback_query(callback_query)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
message = update.get("message", {})
|
message = update.get("message", {})
|
||||||
text = message.get("text", "")
|
text = message.get("text", "")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ _RATE_WINDOW = 600 # 10 minutes
|
||||||
_SIGNAL_RATE_LIMIT = 10
|
_SIGNAL_RATE_LIMIT = 10
|
||||||
_SIGNAL_RATE_WINDOW = 60 # 1 minute
|
_SIGNAL_RATE_WINDOW = 60 # 1 minute
|
||||||
|
|
||||||
|
_AUTH_REGISTER_RATE_LIMIT = 3
|
||||||
|
_AUTH_REGISTER_RATE_WINDOW = 600 # 10 minutes
|
||||||
|
|
||||||
|
|
||||||
def _get_client_ip(request: Request) -> str:
|
def _get_client_ip(request: Request) -> str:
|
||||||
return (
|
return (
|
||||||
|
|
@ -55,3 +58,10 @@ async def rate_limit_signal(request: Request) -> None:
|
||||||
count = await db.rate_limit_increment(key, _SIGNAL_RATE_WINDOW)
|
count = await db.rate_limit_increment(key, _SIGNAL_RATE_WINDOW)
|
||||||
if count > _SIGNAL_RATE_LIMIT:
|
if count > _SIGNAL_RATE_LIMIT:
|
||||||
raise HTTPException(status_code=429, detail="Too Many Requests")
|
raise HTTPException(status_code=429, detail="Too Many Requests")
|
||||||
|
|
||||||
|
|
||||||
|
async def rate_limit_auth_register(request: Request) -> None:
|
||||||
|
key = f"authreg:{_get_client_ip(request)}"
|
||||||
|
count = await db.rate_limit_increment(key, _AUTH_REGISTER_RATE_WINDOW)
|
||||||
|
if count > _AUTH_REGISTER_RATE_LIMIT:
|
||||||
|
raise HTTPException(status_code=429, detail="Too Many Requests")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
|
|
@ -44,3 +44,25 @@ class AdminSetPasswordRequest(BaseModel):
|
||||||
|
|
||||||
class AdminBlockRequest(BaseModel):
|
class AdminBlockRequest(BaseModel):
|
||||||
is_blocked: bool
|
is_blocked: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscriptionKeys(BaseModel):
|
||||||
|
p256dh: str
|
||||||
|
auth: str
|
||||||
|
|
||||||
|
|
||||||
|
class PushSubscription(BaseModel):
|
||||||
|
endpoint: str
|
||||||
|
keys: PushSubscriptionKeys
|
||||||
|
|
||||||
|
|
||||||
|
class AuthRegisterRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
login: str = Field(..., min_length=3, max_length=30, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||||
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
push_subscription: Optional[PushSubscription] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthRegisterResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
|
|
||||||
35
backend/push.py
Normal file
35
backend/push.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from backend import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_push(subscription_json: str, title: str, body: str) -> None:
|
||||||
|
"""Send a Web Push notification. Swallows all errors — never raises."""
|
||||||
|
if not config.VAPID_PRIVATE_KEY:
|
||||||
|
logger.warning("VAPID_PRIVATE_KEY not configured — push notification skipped")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import pywebpush # type: ignore[import]
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("pywebpush not installed — push notification skipped")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
subscription_info = json.loads(subscription_json)
|
||||||
|
data = json.dumps({"title": title, "body": body})
|
||||||
|
vapid_claims = {"sub": f"mailto:{config.VAPID_CLAIMS_EMAIL or 'admin@example.com'}"}
|
||||||
|
|
||||||
|
await asyncio.to_thread(
|
||||||
|
pywebpush.webpush,
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=data,
|
||||||
|
vapid_private_key=config.VAPID_PRIVATE_KEY,
|
||||||
|
vapid_claims=vapid_claims,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Web Push failed: %s", exc)
|
||||||
|
|
@ -55,6 +55,68 @@ async def send_message(text: str) -> None:
|
||||||
logger.error("Telegram send_message: all 3 attempts failed, message dropped")
|
logger.error("Telegram send_message: all 3 attempts failed, message dropped")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_registration_notification(
|
||||||
|
reg_id: int, login: str, email: str, created_at: str
|
||||||
|
) -> None:
|
||||||
|
"""Send registration request notification to admin with approve/reject inline buttons.
|
||||||
|
Swallows all errors — never raises."""
|
||||||
|
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage")
|
||||||
|
text = (
|
||||||
|
f"📋 Новая заявка на регистрацию\n\n"
|
||||||
|
f"Login: {login}\nEmail: {email}\nДата: {created_at}"
|
||||||
|
)
|
||||||
|
reply_markup = {
|
||||||
|
"inline_keyboard": [[
|
||||||
|
{"text": "✅ Одобрить", "callback_data": f"approve:{reg_id}"},
|
||||||
|
{"text": "❌ Отклонить", "callback_data": f"reject:{reg_id}"},
|
||||||
|
]]
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
url,
|
||||||
|
json={
|
||||||
|
"chat_id": config.ADMIN_CHAT_ID,
|
||||||
|
"text": text,
|
||||||
|
"reply_markup": reply_markup,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(
|
||||||
|
"send_registration_notification failed %s: %s",
|
||||||
|
resp.status_code,
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("send_registration_notification error: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def answer_callback_query(callback_query_id: str) -> None:
|
||||||
|
"""Answer a Telegram callback query. Swallows all errors."""
|
||||||
|
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="answerCallbackQuery")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(url, json={"callback_query_id": callback_query_id})
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error("answerCallbackQuery failed %s: %s", resp.status_code, resp.text)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("answerCallbackQuery error: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def edit_message_text(chat_id: str | int, message_id: int, text: str) -> None:
|
||||||
|
"""Edit a Telegram message text. Swallows all errors."""
|
||||||
|
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="editMessageText")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
url, json={"chat_id": chat_id, "message_id": message_id, "text": text}
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error("editMessageText failed %s: %s", resp.status_code, resp.text)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("editMessageText error: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
async def set_webhook(url: str, secret: str) -> None:
|
async def set_webhook(url: str, secret: str) -> None:
|
||||||
api_url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="setWebhook")
|
api_url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="setWebhook")
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,5 @@ aiosqlite>=0.20.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
pydantic>=2.0
|
pydantic>=2.0
|
||||||
|
email-validator>=2.0.0
|
||||||
|
pywebpush>=2.0.0
|
||||||
|
|
|
||||||
|
|
@ -73,13 +73,15 @@ def make_app_client():
|
||||||
"""
|
"""
|
||||||
Async context manager that:
|
Async context manager that:
|
||||||
1. Assigns a fresh temp-file DB path
|
1. Assigns a fresh temp-file DB path
|
||||||
2. Mocks Telegram setWebhook and sendMessage
|
2. Mocks Telegram setWebhook, sendMessage, answerCallbackQuery, editMessageText
|
||||||
3. Runs the FastAPI lifespan (startup → test → shutdown)
|
3. Runs the FastAPI lifespan (startup → test → shutdown)
|
||||||
4. Yields an httpx.AsyncClient wired to the app
|
4. Yields an httpx.AsyncClient wired to the app
|
||||||
"""
|
"""
|
||||||
tg_set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
|
tg_set_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/setWebhook"
|
||||||
send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
send_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
||||||
get_me_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe"
|
get_me_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/getMe"
|
||||||
|
answer_cb_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/answerCallbackQuery"
|
||||||
|
edit_msg_url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/editMessageText"
|
||||||
|
|
||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def _ctx():
|
async def _ctx():
|
||||||
|
|
@ -96,6 +98,12 @@ def make_app_client():
|
||||||
mock_router.post(send_url).mock(
|
mock_router.post(send_url).mock(
|
||||||
return_value=httpx.Response(200, json={"ok": True})
|
return_value=httpx.Response(200, json={"ok": True})
|
||||||
)
|
)
|
||||||
|
mock_router.post(answer_cb_url).mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True})
|
||||||
|
)
|
||||||
|
mock_router.post(edit_msg_url).mock(
|
||||||
|
return_value=httpx.Response(200, json={"ok": True})
|
||||||
|
)
|
||||||
|
|
||||||
with mock_router:
|
with mock_router:
|
||||||
async with app.router.lifespan_context(app):
|
async with app.router.lifespan_context(app):
|
||||||
|
|
|
||||||
349
tests/test_baton_008.py
Normal file
349
tests/test_baton_008.py
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-008: Registration flow with Telegram admin approval.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. POST /api/auth/register returns 201 with status='pending' on valid input
|
||||||
|
2. POST /api/auth/register returns 409 on email or login conflict
|
||||||
|
3. POST /api/auth/register returns 422 on invalid email/login/password
|
||||||
|
4. Telegram notification is fire-and-forget — 201 is returned even if Telegram fails
|
||||||
|
5. Webhook callback_query approve → db status='approved', push task fired if subscription present
|
||||||
|
6. Webhook callback_query reject → db status='rejected'
|
||||||
|
7. Webhook callback_query with unknown reg_id → returns {"ok": True} gracefully
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
|
||||||
|
os.environ.setdefault("CHAT_ID", "-1001234567890")
|
||||||
|
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
|
||||||
|
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
|
||||||
|
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||||
|
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import make_app_client
|
||||||
|
|
||||||
|
_WEBHOOK_SECRET = "test-webhook-secret"
|
||||||
|
_WEBHOOK_HEADERS = {"X-Telegram-Bot-Api-Secret-Token": _WEBHOOK_SECRET}
|
||||||
|
|
||||||
|
_VALID_PAYLOAD = {
|
||||||
|
"email": "user@example.com",
|
||||||
|
"login": "testuser",
|
||||||
|
"password": "strongpassword123",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Happy path — 201
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_returns_201_pending():
|
||||||
|
"""Valid registration request returns 201 with status='pending'."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
|
||||||
|
assert resp.status_code == 201, f"Expected 201, got {resp.status_code}: {resp.text}"
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "pending"
|
||||||
|
assert "message" in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_fire_and_forget_telegram_error_still_returns_201():
|
||||||
|
"""Telegram failure must not break 201 — fire-and-forget pattern."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch(
|
||||||
|
"backend.telegram.send_registration_notification",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=Exception("Telegram down"),
|
||||||
|
):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "email": "other@example.com", "login": "otheruser"},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert resp.status_code == 201, f"Telegram error must not break 201, got {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Conflict — 409
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_409_on_duplicate_email():
|
||||||
|
"""Duplicate email returns 409."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
r1 = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
assert r1.status_code == 201, f"First registration failed: {r1.text}"
|
||||||
|
|
||||||
|
r2 = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "login": "differentlogin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r2.status_code == 409, f"Expected 409 on duplicate email, got {r2.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_409_on_duplicate_login():
|
||||||
|
"""Duplicate login returns 409."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
r1 = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
assert r1.status_code == 201, f"First registration failed: {r1.text}"
|
||||||
|
|
||||||
|
r2 = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "email": "different@example.com"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r2.status_code == 409, f"Expected 409 on duplicate login, got {r2.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Validation — 422
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_422_invalid_email():
|
||||||
|
"""Invalid email format returns 422."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "email": "not-an-email"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, f"Expected 422 on invalid email, got {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_422_short_login():
|
||||||
|
"""Login shorter than 3 chars returns 422."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "login": "ab"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, f"Expected 422 on short login, got {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_422_login_invalid_chars():
|
||||||
|
"""Login with spaces/special chars returns 422."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "login": "invalid login!"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, f"Expected 422 on login with spaces, got {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_422_short_password():
|
||||||
|
"""Password shorter than 8 chars returns 422."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "password": "short"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, f"Expected 422 on short password, got {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Telegram notification is sent to ADMIN_CHAT_ID
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_register_sends_notification_to_admin():
|
||||||
|
"""Registration triggers send_registration_notification with correct data."""
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
async def _capture(reg_id, login, email, created_at):
|
||||||
|
calls.append({"reg_id": reg_id, "login": login, "email": email})
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", side_effect=_capture):
|
||||||
|
await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(calls) == 1, f"Expected 1 notification call, got {len(calls)}"
|
||||||
|
assert calls[0]["login"] == _VALID_PAYLOAD["login"]
|
||||||
|
assert calls[0]["email"] == _VALID_PAYLOAD["email"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Webhook callback_query — approve
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_approve_updates_db_status():
|
||||||
|
"""approve callback updates registration status to 'approved' in DB."""
|
||||||
|
from backend import db as _db
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
reg_resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
assert reg_resp.status_code == 201
|
||||||
|
|
||||||
|
# We need reg_id — get it from DB directly
|
||||||
|
reg_id = None
|
||||||
|
from tests.conftest import temp_db as _temp_db # noqa: F401 — already active
|
||||||
|
from backend import config as _cfg
|
||||||
|
import aiosqlite
|
||||||
|
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
reg_id = row["id"] if row else None
|
||||||
|
|
||||||
|
assert reg_id is not None, "Registration not found in DB"
|
||||||
|
|
||||||
|
cb_payload = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_001",
|
||||||
|
"data": f"approve:{reg_id}",
|
||||||
|
"message": {
|
||||||
|
"message_id": 42,
|
||||||
|
"chat": {"id": 5694335584},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/webhook/telegram",
|
||||||
|
json=cb_payload,
|
||||||
|
headers=_WEBHOOK_HEADERS,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"ok": True}
|
||||||
|
|
||||||
|
# Verify DB status updated
|
||||||
|
reg = await _db.get_registration(reg_id)
|
||||||
|
assert reg is not None
|
||||||
|
assert reg["status"] == "approved", f"Expected status='approved', got {reg['status']!r}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_approve_fires_push_when_subscription_present():
|
||||||
|
"""approve callback triggers send_push when push_subscription is set."""
|
||||||
|
push_sub = {
|
||||||
|
"endpoint": "https://fcm.googleapis.com/fcm/send/test",
|
||||||
|
"keys": {"p256dh": "BQABC", "auth": "xyz"},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
reg_resp = await client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={**_VALID_PAYLOAD, "push_subscription": push_sub},
|
||||||
|
)
|
||||||
|
assert reg_resp.status_code == 201
|
||||||
|
|
||||||
|
from backend import config as _cfg
|
||||||
|
import aiosqlite
|
||||||
|
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
reg_id = row["id"] if row else None
|
||||||
|
assert reg_id is not None
|
||||||
|
|
||||||
|
cb_payload = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_002",
|
||||||
|
"data": f"approve:{reg_id}",
|
||||||
|
"message": {"message_id": 43, "chat": {"id": 5694335584}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
push_calls: list = []
|
||||||
|
|
||||||
|
async def _capture_push(sub_json, title, body):
|
||||||
|
push_calls.append(sub_json)
|
||||||
|
|
||||||
|
with patch("backend.push.send_push", side_effect=_capture_push):
|
||||||
|
await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(push_calls) == 1, f"Expected 1 push call, got {len(push_calls)}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Webhook callback_query — reject
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_reject_updates_db_status():
|
||||||
|
"""reject callback updates registration status to 'rejected' in DB."""
|
||||||
|
from backend import db as _db
|
||||||
|
|
||||||
|
async with make_app_client() as client:
|
||||||
|
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||||
|
reg_resp = await client.post("/api/auth/register", json=_VALID_PAYLOAD)
|
||||||
|
assert reg_resp.status_code == 201
|
||||||
|
|
||||||
|
from backend import config as _cfg
|
||||||
|
import aiosqlite
|
||||||
|
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
reg_id = row["id"] if row else None
|
||||||
|
assert reg_id is not None
|
||||||
|
|
||||||
|
cb_payload = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_003",
|
||||||
|
"data": f"reject:{reg_id}",
|
||||||
|
"message": {"message_id": 44, "chat": {"id": 5694335584}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
reg = await _db.get_registration(reg_id)
|
||||||
|
assert reg is not None
|
||||||
|
assert reg["status"] == "rejected", f"Expected status='rejected', got {reg['status']!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. Unknown reg_id — graceful handling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_webhook_callback_unknown_reg_id_returns_ok():
|
||||||
|
"""callback_query with unknown reg_id returns ok without error."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
cb_payload = {
|
||||||
|
"callback_query": {
|
||||||
|
"id": "cq_999",
|
||||||
|
"data": "approve:99999",
|
||||||
|
"message": {"message_id": 1, "chat": {"id": 5694335584}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"ok": True}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue