"""Ревью-тесты стейт-машины логина без реального Telegram.
Фейковый TelegramClient + фейковый telethon.errors/functions: каждый сценарий
(SMS, 2FA, отмена, QR, sweeper, осиротевшие строки) проверяется против
реального login_flow и реальной SQLite-схемы. Тесты self-contained: БД и
клиент создаются в тесте, соединение закрывается до выхода (тот же loop).
"""
import asyncio
import sys
import types
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
import pytest
BACKEND = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND))
# --- фейковый telethon.errors/functions -----------------------------------
class _TGError(Exception): # noqa: N818
def __init__(self, *args, **kwargs):
super().__init__(args or kwargs)
if args and isinstance(args[0], int):
self.seconds = args[0]
PHONE_INVALID = type("PhoneNumberInvalidError", (_TGError,), {})
FLOOD_WAIT = type("FloodWaitError", (_TGError,), {})
API_ID_INVALID = type("ApiIdInvalidError", (_TGError,), {})
CODE_INVALID = type("PhoneCodeInvalidError", (_TGError,), {})
CODE_EXPIRED = type("PhoneCodeExpiredError", (_TGError,), {})
PASSWORD_NEEDED = type("SessionPasswordNeededError", (_TGError,), {})
PASSWORD_HASH_INVALID = type("PasswordHashInvalidError", (_TGError,), {})
fake_errors = types.SimpleNamespace(
PhoneNumberInvalidError=PHONE_INVALID, FloodWaitError=FLOOD_WAIT,
ApiIdInvalidError=API_ID_INVALID, PhoneCodeInvalidError=CODE_INVALID,
PhoneCodeExpiredError=CODE_EXPIRED, SessionPasswordNeededError=PASSWORD_NEEDED,
PasswordHashInvalidError=PASSWORD_HASH_INVALID,
)
class FakeInner:
"""Поведение задаётся тестом: sign_in_effect / qr-эффекты / hang_cancel."""
def __init__(self):
self.alive = False
self.phone = ""
self.phone_code_hash = "h" * 32
self.hang_cancel = False
self.cancel_calls = 0
self.sign_in_effect = None # None = успех; класс-исключение
self.user_me = SimpleNamespace(
id=42, phone="+79991234567", username="tester",
first_name="Тест", last_name="Юзер",
)
async def connect(self):
self.alive = True
def is_connected(self):
return self.alive
async def send_code_request(self, phone):
assert self.alive
self.phone = phone
return SimpleNamespace(phone_code_hash=self.phone_code_hash)
async def sign_in(self, *args, **kwargs):
if self.sign_in_effect is not None:
raise self.sign_in_effect()
return SimpleNamespace()
async def get_me(self):
return self.user_me
async def disconnect(self):
self.alive = False
class FakeQR:
def __init__(self):
self.urls = ["tg://login?token=AAA", "tg://login?token=BBB"]
self.n = 0
self.wait_effect = None # None = успех (скан принят)
self.recreate_effect = None # исключение при первом recreate
self.user_me = None
self.url_prop = "" # пустой url имитирует потерю токена
@property
def url(self):
return self.url_prop or self.urls[min(self.n, len(self.urls) - 1)]
async def wait(self, *a, **kw):
if self.wait_effect is not None:
raise self.wait_effect()
return self.user_me
async def recreate(self):
if self.recreate_effect is not None and self.n == 0:
raise self.recreate_effect()
self.n += 1
class FakeClient:
def __init__(self, api_id, api_hash):
self.inner = FakeInner()
self.api_id = api_id
self.qr = None
@property
def session(self):
return SimpleNamespace(save=lambda: "session-string")
async def connect(self):
await self.inner.connect()
return self
def is_connected(self):
return self.inner.is_connected()
async def send_code_request(self, phone):
return await self.inner.send_code_request(phone)
async def qr_login(self):
self.qr = FakeQR()
return self.qr
async def sign_in(self, *args, **kwargs):
return await self.inner.sign_in(*args, **kwargs)
async def get_me(self):
return await self.inner.get_me()
async def disconnect(self):
await self.inner.disconnect()
async def __call__(self, request):
# fake_functions возвращает ("cancel", kw) — так тест видит сам запрос
if isinstance(request, tuple) and request[0] == "cancel":
self.inner.cancel_calls += 1
if self.inner.hang_cancel:
await asyncio.sleep(3600)
return SimpleNamespace()
class FakeManager:
def __init__(self):
self.pending = {}
self.drops = []
def put_pending(self, login_id, client):
self.pending[login_id] = client
async def drop_pending(self, login_id, disconnect=True):
client = self.pending.pop(login_id, None)
if client is not None and disconnect:
await client.disconnect()
def pending_client(self, login_id):
return self.pending.get(login_id)
async def drop(self, account_id):
self.drops.append(account_id)
fake_functions = types.SimpleNamespace(
auth=types.SimpleNamespace(
CancelCodeRequest=lambda **kw: ("cancel", kw),
),
)
# --- каркас ----------------------------------------------------------------
async def setup(tmp_path, monkeypatch):
monkeypatch.setenv("TGCLIENT_DB_PATH", str(tmp_path / "t.db"))
monkeypatch.setenv("TGCLIENT_API_ID", "1")
monkeypatch.setenv("TGCLIENT_API_HASH", "hash")
monkeypatch.setenv("TGCLIENT_AUTH_CLIENT_ID", "")
import app.config as config
import app.db as appdb
import app.main as appmain
import app.tg.login_flow as flow
config.get_settings.cache_clear()
await appdb.init_db()
conn = appdb.get_db()
now = datetime.now(timezone.utc).isoformat()
await conn.execute(
"INSERT OR IGNORE INTO users (user_id, email, created_at, updated_at)"
" VALUES ('u1','owner@example.com',?,?)", (now, now))
await conn.commit()
manager = FakeManager()
monkeypatch.setattr(appmain, "get_account_manager", lambda: manager, raising=False)
flow.errors = fake_errors
flow.functions = fake_functions
flow.TelegramClientWithSession = FakeClient
reports = []
def fake_report(event, value): # synapse_report.report — sync fire-and-forget
reports.append((event, value))
monkeypatch.setattr(flow, "report", fake_report)
return flow, manager, reports
def iso(minutes: float, *, past: bool = False) -> str:
now = datetime.now(timezone.utc)
delta = now + timedelta(minutes=minutes)
if past:
delta = now - timedelta(minutes=minutes)
return delta.isoformat()
async def db_row(login_id) -> dict:
import app.db as appdb
c = await appdb.get_db().execute(
"SELECT * FROM login_sessions WHERE id = ?", (login_id,))
r = await c.fetchone()
return dict(r) if r is not None else None
pytestmark = pytest.mark.asyncio
# --- SMS-путь --------------------------------------------------------------
@pytest.mark.asyncio
async def test_sms_start_reuse_wrong_code_3x_410(monkeypatch, tmp_path):
flow, manager, reports = await setup(tmp_path, monkeypatch)
out = await flow.login_start("+79991234567", "u1", "work")
assert out["step"] == "awaiting_code"
assert out["phone_masked"] == "799***567" # маска first3+***+last3
assert out["login_id"] in manager.pending
# reuse: второй start того же phone возвращает ту же сессию
out2 = await flow.login_start("+79991234567", "u1", "")
assert out2["reused"] is True
assert out2["login_id"] == out["login_id"]
# три неправильных кода: первые два — attempts_left, третий — 410 + чистка
fake = flow.TelegramClientWithSession(1, "h")
fake.inner.sign_in_effect = CODE_INVALID
manager.put_pending(out["login_id"], fake) # подменяем живой клиент
r1 = await flow.login_code(out["login_id"], "11111", "u1")
assert r1["attempts_left"] == 2
r2 = await flow.login_code(out["login_id"], "11111", "u1")
assert r2["attempts_left"] == 1
with pytest.raises(flow.DomainError) as e3:
await flow.login_code(out["login_id"], "11111", "u1")
assert getattr(e3.value, "code", None) == 410
assert manager.pending == {}
assert await db_row(out["login_id"]) is None # строка тоже чистится
@pytest.mark.asyncio
async def test_sms_password_2fa_and_attempts(monkeypatch, tmp_path):
flow, manager, reports = await setup(tmp_path, monkeypatch)
out = await flow.login_start("+79991234567", "u1", "")
fake = manager.pending[out["login_id"]]
fake.inner.sign_in_effect = PASSWORD_NEEDED
r = await flow.login_code(out["login_id"], "12345", "u1")
assert r == {"login_id": out["login_id"], "step": "awaiting_password"}
fake.inner.sign_in_effect = PASSWORD_HASH_INVALID
r2 = await flow.login_password(out["login_id"], "bad", "u1")
assert r2["attempts_left"] == 1
with pytest.raises(flow.DomainError):
await flow.login_password(out["login_id"], "bad", "u1")
assert manager.pending == {}
@pytest.mark.asyncio
async def test_sms_success_finalizes_and_marks_done_row(monkeypatch, tmp_path):
flow, manager, reports = await setup(tmp_path, monkeypatch)
out = await flow.login_start("+79991234567", "u1", "т")
await flow.login_code(out["login_id"], "12345", "u1")
r = await db_row(out["login_id"])
assert r["step"] == "done"
assert r["phone_code_hash"] == "" # хеш кода стёрт
assert manager.pending == {} # RAM-клиент отдан пулу
import app.db as appdb
c = await appdb.get_db().execute(
"SELECT id FROM accounts WHERE phone = '79991234567'")
assert (await c.fetchone()) is not None
assert any(er == "tg_account_logged_in" for er, _ in reports)
# полл читает настоящий успех
st = await flow.login_status(out["login_id"], "u1")
assert st["step"] == "done"
# повторный start того же phone: строка done не лочит новый логин
out3 = await flow.login_start("+79991234567", "u1", "")
assert out3.get("reused") is None and out3["login_id"] != out["login_id"]
@pytest.mark.asyncio
async def test_login_code_on_done_row_conflict(monkeypatch, tmp_path):
flow, manager, reports = await setup(tmp_path, monkeypatch)
# done-строка недоступна для второго кода: 409, а не тихий успех
out = await flow.login_start("+79991234567", "u1", "")
fake = manager.pending[out["login_id"]]
await flow.login_code(out["login_id"], "12345", "u1") # → done
with pytest.raises(flow.DomainError) as e:
await flow.login_code(out["login_id"], "12345", "u1")
assert getattr(e.value, "code", None) == 409
@pytest.mark.asyncio
async def test_cancel_with_hanging_mtproto_connection(monkeypatch, tmp_path):
"""Кнопка отмены при полумёртвом коннекте: cancelCode под таймаутом,
отмена не зависает, строка чистится, вызов кода был сделан."""
flow, manager, reports = await setup(tmp_path, monkeypatch)
out = await flow.login_start("+79991234567", "u1", "")
fake = manager.pending[out["login_id"]]
fake.inner.hang_cancel = True
await asyncio.wait_for(flow.login_cancel(out["login_id"], "u1"), timeout=10)
assert fake.inner.cancel_calls == 1
assert manager.pending == {}
assert await db_row(out["login_id"]) is None
# --- QR-путь ---------------------------------------------------------------
@pytest.mark.asyncio
async def test_qr_scan_success_finalizes(monkeypatch, tmp_path):
flow, manager, reports = await setup(tmp_path, monkeypatch)
out = await flow.login_qr_start("u1", "qr")
assert out["step"] == "awaiting_scan" and out["qr_url"].startswith("tg://login")
fake = manager.pending[out["login_id"]]
fake.qr.user_me = fake.inner.user_me # скан принят: wait() возвращает юзера
await asyncio.sleep(0.3) # waiter финализирует
r = await db_row(out["login_id"])
assert r["step"] == "done"
assert r["phone"] == "+79991234567"
st = await flow.login_status(out["login_id"], "u1")
assert st["step"] == "done"
assert manager.pending == {}
assert any(er == "tg_account_logged_in" for er, _ in reports)
@pytest.mark.asyncio
async def test_qr_password_step_via_status(monkeypatch, tmp_path):
flow, manager, reports = await setup(tmp_path, monkeypatch)
out = await flow.login_qr_start("u1", "")
fake = manager.pending[out["login_id"]]
fake.qr.wait_effect = PASSWORD_NEEDED # юзер с 2FA
await asyncio.sleep(0.3)
st = await flow.login_status(out["login_id"], "u1")
assert st["step"] == "awaiting_password"
# 2FA добивается обычным пароль-маршрутом
fake.inner.sign_in_effect = PASSWORD_HASH_INVALID
r2 = await flow.login_password(out["login_id"], "bad", "u1")
assert r2["attempts_left"] == 1
with pytest.raises(flow.DomainError):
await flow.login_password(out["login_id"], "bad", "u1")
r = await db_row(out["login_id"])
assert r is None # 2 промаха: строка чистится
@pytest.mark.asyncio
async def test_qr_recreate_failure_marks_error(monkeypatch, tmp_path):
flow, manager, reports = await setup(tmp_path, monkeypatch)
out = await flow.login_qr_start("u1", "")
fake = manager.pending[out["login_id"]]
fake.qr.wait_effect = asyncio.TimeoutError # токен истёк
fake.qr.recreate_effect = OSError("no network") # и обновить не вышло
await asyncio.sleep(0.5)
st = await flow.login_status(out["login_id"], "u1")
assert st["step"] == "error"
assert st["last_error"]
r = await db_row(out["login_id"])
assert r["phone_code_hash"] == ""
@pytest.mark.asyncio
async def test_qr_cancel_stops_waiter(monkeypatch, tmp_path):
flow, manager, reports = await setup(tmp_path, monkeypatch)
out = await flow.login_qr_start("u1", "")
fake = manager.pending[out["login_id"]]
fake.qr.wait_effect = asyncio.futures.Future() # ждёт скана вечно
await flow.login_cancel(out["login_id"], "u1")
assert await db_row(out["login_id"]) is None
assert manager.pending == {}
await asyncio.sleep(0.2) # waiter погашен cancel, без побочных
@pytest.mark.asyncio
async def test_qr_service_restart_orphan(monkeypatch, tmp_path):
"""Рестарт: строка есть, waiter-таски нет — статус честный 410 и чистка."""
import app.db as appdb
flow, manager, reports = await setup(tmp_path, monkeypatch)
conn = appdb.get_db()
await conn.execute(
"INSERT INTO login_sessions (id, user_id, phone, phone_code_hash, step,"
" expires_at, created_at) VALUES ('orph','u1','','','awaiting_scan',?,?)",
(iso(15), iso(0)),
)
await conn.commit()
with pytest.raises(flow.DomainError) as e:
await flow.login_status("orph", "u1")
assert getattr(e.value, "code", None) == 410
c = await conn.execute("SELECT COUNT(*) AS n FROM login_sessions")
assert (await c.fetchone())["n"] == 0
# --- sweeper ---------------------------------------------------------------
@pytest.mark.asyncio
async def test_sweeper_skips_terminal_and_purges_orphans(monkeypatch, tmp_path):
import app.db as appdb
flow, manager, reports = await setup(tmp_path, monkeypatch)
conn = appdb.get_db()
await conn.execute(
"INSERT INTO login_sessions (id, user_id, phone, phone_code_hash, step,"
" expires_at, created_at) VALUES ('a','u1','+79991234561','h','awaiting_code',?,?)",
(iso(5), iso(20, past=True)))
# осиротевший свежий
await conn.execute(
"INSERT INTO login_sessions (id, user_id, phone, phone_code_hash, step,"
" expires_at, created_at) VALUES ('b','u1','+79991234562','h','awaiting_scan',?,?)",
(iso(10), iso(0)))
# терминальный done (свежий) — не трогаем
await conn.execute(
"INSERT INTO login_sessions (id, user_id, phone, phone_code_hash, step,"
" expires_at, created_at) VALUES ('c','u1','+79991234563','','done',?,?)",
(iso(0.5), iso(30, past=True)))
# терминальный done истёк — удалить
await conn.execute(
"INSERT INTO login_sessions (id, user_id, phone, phone_code_hash, step,"
" expires_at, created_at) VALUES ('d','u1','+79991234564','','done',?,?)",
(iso(0.5, past=True), iso(30, past=True)))
await conn.commit()
cancelled = await flow.purge_stale_logins()
assert cancelled == 0 # RAM пуст у живых — осиротевшие, не cancel
c = await conn.execute("SELECT id FROM login_sessions ORDER BY id")
# свежий осиротевший 'b' живёт до TTL, свежий done 'c' — до своего expires
assert [r["id"] for r in await c.fetchall()] == ["b", "c"] # свежий done живёт
# а его expires_at наступил — чистится в следующем прогоне
await conn.execute("UPDATE login_sessions SET expires_at = ? WHERE id = 'c'",
(iso(0.1, past=True),))
await conn.commit()
await flow.purge_stale_logins()
c = await conn.execute("SELECT id FROM login_sessions ORDER BY id")
assert [r["id"] for r in await c.fetchall()] == ["b"] # 'b' живёт до TTL
assert await db_row("b") is not None
await appdb.close_db()
@pytest.mark.asyncio
async def test_sweeper_cancels_alive_stale_login(monkeypatch, tmp_path):
"""RAM-клиент жив, логин завис старше stale_min — настоящая отмена."""
flow, manager, reports = await setup(tmp_path, monkeypatch)
out = await flow.login_start("+79991234567", "u1", "")
# искусственно состарим created_at, сохранив expires_at
import app.db as appdb
conn = appdb.get_db()
await conn.execute("UPDATE login_sessions SET created_at = ? WHERE id = ?",
(iso(30, past=True), out["login_id"]))
await conn.commit()
cancelled = await flow.purge_stale_logins()
assert cancelled == 1
assert manager.pending == {}
assert await db_row(out["login_id"]) is None
await appdb.close_db()