Files
evo-sync/web/routes/evotor_webhooks.py
mguschin fc65e591b3 test: add test suite with 65 tests, 73% coverage
- Unit tests: password hashing, notification providers, webhook field parsing
- Integration tests: auth routes (register/login/confirm-email/logout),
  invite flow, Evotor webhooks (/user/create, /user/verify, /user/token),
  admin panel (access control, activate/suspend/delete/reset-password)
- conftest: SQLite in-memory engine, transactional sessions, factory-boy
  factories (UserFactory with UserRoleEnum variants)
- Fix bcrypt: replace passlib (broken on Python 3.14 + bcrypt 5.x) with
  direct bcrypt calls; drop passlib from requirements.txt
- Fix datetime.utcnow() deprecation across routes and tests
- Fix Jinja2 TemplateResponse signature (request as first positional arg)
- Add coverage config to pyproject.toml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:27:42 +03:00

270 lines
9.2 KiB
Python

"""
Evotor webhook endpoints.
POST /user/create — Evotor creates a new subscriber; we create/link a local user and return a token.
POST /user/verify — Evotor verifies credentials for a user trying to log in via the Evotor interface.
POST /user/token — Evotor sends us its own API token for the user.
"""
import json
import logging
import secrets
from datetime import datetime, timezone, timedelta
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from web.auth.password import verify_password
from web.config import settings
from web.database import get_db
from web.models.connections import EvotorConnection
from web.models.user import User, UserRoleEnum, UserStatusEnum
from web.notifications.tasks import send_email_task
logger = logging.getLogger(__name__)
router = APIRouter()
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
def _verify_secret(request: Request) -> bool:
secret = settings.EVOTOR_WEBHOOK_SECRET
if not secret:
return True # dev mode: no secret configured
auth = request.headers.get("Authorization", "")
return auth == f"Bearer {secret}"
def _parse_custom_fields(raw: Any) -> dict:
"""Extract known fields from Evotor customField (may be JSON string or dict)."""
if raw is None:
return {}
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
return parsed
except (json.JSONDecodeError, ValueError):
pass
return {}
def _upsert_evotor_connection(
db: Session,
user_id: int | None,
evotor_user_id: str,
access_token: str | None = None,
) -> str:
"""Create or update an evotor_connections row; always regenerates api_token."""
api_token = secrets.token_urlsafe(32)
conn = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == evotor_user_id
).first()
now = datetime.now(timezone.utc).replace(tzinfo=None)
if conn:
conn.api_token = api_token
if user_id is not None:
conn.user_id = user_id
if access_token:
conn.access_token = access_token
conn.updated_at = now
else:
conn = EvotorConnection(
user_id=user_id,
evotor_user_id=evotor_user_id,
access_token=access_token or "",
api_token=api_token,
connected_at=now,
updated_at=now,
)
db.add(conn)
db.flush()
return api_token
@router.post("/user/create")
async def user_create(request: Request, db: Session = Depends(get_db)):
if not _verify_secret(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
evotor_user_id: str = body.get("userId", "")
if not evotor_user_id:
return JSONResponse({"error": "userId required"}, status_code=400)
custom = _parse_custom_fields(body.get("customField"))
email = (custom.get("email") or "").strip().lower() or None
phone = (custom.get("phone") or "").strip() or None
first_name = (custom.get("first_name") or custom.get("firstName") or "").strip() or None
last_name = (custom.get("last_name") or custom.get("lastName") or "").strip() or None
# Try to find existing user
user: User | None = None
# 1. By evotor_user_id
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first()
# 2. By email
if user is None and email:
user = db.query(User).filter(User.email == email).first()
# 3. By phone
if user is None and phone:
user = db.query(User).filter(User.phone == phone).first()
now = datetime.now(timezone.utc).replace(tzinfo=None)
if user:
# Link Evotor to existing user
user.evotor_user_id = evotor_user_id
user.evotor_meta = custom or body
if user.status == UserStatusEnum.pending:
user.status = UserStatusEnum.active
db.flush()
else:
# Create new pending user
user = User(
first_name=first_name or "",
last_name=last_name or "",
email=email or f"{evotor_user_id}@evotor.placeholder",
phone=phone or "",
password_hash=None,
role=UserRoleEnum.user,
status=UserStatusEnum.pending,
evotor_user_id=evotor_user_id,
evotor_meta=custom or body,
created_at=now,
updated_at=now,
)
db.add(user)
db.flush() # get user.id
# Generate invite
invite_token = secrets.token_urlsafe(32)
user.invite_token = invite_token
user.invite_expires = now + timedelta(hours=settings.INVITE_EXPIRE_HOURS)
api_token = _upsert_evotor_connection(db, user.id, evotor_user_id)
db.commit()
# Send invite email if we have a real email address
if email:
invite_url = f"{settings.BASE_URL}/invite?token={invite_token}"
html = (
f"<p>Здравствуйте!</p>"
f"<p>Вам открыт доступ к ЭВОСИНК. Завершите регистрацию по ссылке:</p>"
f'<p><a href="{invite_url}">{invite_url}</a></p>'
f"<p>Ссылка действительна {settings.INVITE_EXPIRE_HOURS} часов.</p>"
)
send_email_task.delay(email, "Приглашение в ЭВОСИНК", html)
else:
logger.info("No email for evotor_user_id=%s, invite URL: %s/invite?token=%s",
evotor_user_id, settings.BASE_URL, invite_token)
return JSONResponse({"userId": evotor_user_id, "token": api_token})
@router.post("/user/verify")
async def user_verify(request: Request, db: Session = Depends(get_db)):
if not _verify_secret(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
evotor_user_id: str = body.get("userId", "")
username: str = body.get("username", "").strip()
password: str = body.get("password", "")
if not username or not password:
return JSONResponse({"error": "username and password required"}, status_code=400)
# username is email or phone
user = db.query(User).filter(
or_(User.email == username, User.phone == username)
).first()
if not user or not user.password_hash:
return JSONResponse({"error": "Неверные данные"}, status_code=401)
if user.status == UserStatusEnum.suspended:
return JSONResponse({"error": "Аккаунт заблокирован"}, status_code=403)
if not verify_password(password, user.password_hash):
return JSONResponse({"error": "Неверные данные"}, status_code=401)
# Get or create connection to retrieve api_token
conn = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == (user.evotor_user_id or evotor_user_id)
).first()
if not conn:
# Auto-link: create connection with Evotor userId from request
if evotor_user_id and not user.evotor_user_id:
user.evotor_user_id = evotor_user_id
db.flush()
api_token = _upsert_evotor_connection(db, user.id, evotor_user_id or (user.evotor_user_id or ""))
db.commit()
else:
api_token = conn.api_token or secrets.token_urlsafe(32)
if not conn.api_token:
conn.api_token = api_token
db.commit()
return JSONResponse({"userId": user.evotor_user_id or evotor_user_id, "token": api_token})
@router.post("/user/token")
async def user_token(request: Request, db: Session = Depends(get_db)):
if not _verify_secret(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
evotor_user_id: str = body.get("userId", "")
evotor_token: str = body.get("token", "")
if not evotor_user_id or not evotor_token:
return JSONResponse({"error": "userId and token required"}, status_code=400)
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first()
if not user:
return JSONResponse({"error": "User not found"}, status_code=404)
conn = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == evotor_user_id
).first()
now = datetime.now(timezone.utc).replace(tzinfo=None)
if conn:
conn.access_token = evotor_token
conn.is_online = True
conn.last_checked_at = now
conn.updated_at = now
else:
conn = EvotorConnection(
user_id=user.id,
evotor_user_id=evotor_user_id,
access_token=evotor_token,
api_token=secrets.token_urlsafe(32),
is_online=True,
last_checked_at=now,
connected_at=now,
updated_at=now,
)
db.add(conn)
db.commit()
return JSONResponse({})