diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 31d0f32..30fe62b 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -32,6 +32,8 @@ ) cursor = await db.execute( "SELECT COUNT(*) AS n FROM login_sessions" + " WHERE step NOT IN ('done', 'error') AND expires_at > ?", + (now_iso(),), ) pending_logins = (await cursor.fetchone())["n"] return { diff --git a/backend/app/mcp/server.py b/backend/app/mcp/server.py index 880dbf9..6a6bda2 100644 --- a/backend/app/mcp/server.py +++ b/backend/app/mcp/server.py @@ -74,7 +74,9 @@ пользователю QR по этому URL или ссылку: Telegram → Настройки → Устройства → «Слинковать устройство». Статус — тот же `account_login_status` (step `awaiting_scan` → возможно `awaiting_password` → `done`); токен QR -обновляется сервером (~30 c), не перезапускай логин из-за этого. +обновляется сервером (~30 c), не перезапускай логин из-за этого. step `error` += попытка прервалась (токен не обновился, рестарт сервиса) — прочти +`last_error` и предложи пользователю начать заново. ## Правила - **Мутации (send/edit/delete/upload/call_start и т.п.) — только по явной diff --git a/backend/app/mcp/tools.py b/backend/app/mcp/tools.py index 2793f72..6830b7a 100644 --- a/backend/app/mcp/tools.py +++ b/backend/app/mcp/tools.py @@ -742,8 +742,9 @@ @mcp.tool() async def account_login_status(login_id: str) -> dict: """❓ Текущий шаг логина: awaiting_scan (QR: qr_url)/awaiting_code / - awaiting_password / done, expires_in_sec, last_error. - phone_code_hash наружу не отдаётся.""" + awaiting_password / done (успех после скана/кода) / error (посмотри + last_error: токен QR умер, рестарт сервиса и т.п. — начни вход заново), + expires_in_sec. phone_code_hash наружу не отдаётся.""" return await _tool(lambda: login_status(login_id.strip(), current_user_id())) diff --git a/backend/app/tg/login_flow.py b/backend/app/tg/login_flow.py index d066e09..1a90a4b 100644 --- a/backend/app/tg/login_flow.py +++ b/backend/app/tg/login_flow.py @@ -30,10 +30,25 @@ from app.tg.manager import AccountManager LOGIN_TTL_MIN = 15 +LOGIN_TERMINAL_TTL_MIN = 2 # строки в финальных шагах (done/error): на чтение поллом MAX_CODE_ATTEMPTS = 3 MAX_PASSWORD_ATTEMPTS = 2 +async def _mark_terminal(db, login_id: str, step: str, err: str = "") -> None: + """Терминальный шаг логина: строка живёт ещё 2 минуты, чтобы SPA-полл (и + MCP account_login_status) прочитали настоящий исход — done/error, а не + 404 «строка удалена», который раньше фронт выдавал за успех (ложный тост + «Вход выполнен» на истёкшей/отменённой попытке). Чистит sweeper.""" + await db.execute( + "UPDATE login_sessions SET step = ?, error = ?, phone_code_hash = '', expires_at = ?" + " WHERE id = ?", + (step, err[:200], (_now() + timedelta(minutes=LOGIN_TERMINAL_TTL_MIN)) + .isoformat(), login_id), + ) + await db.commit() + + def _now() -> datetime: return datetime.now(timezone.utc) @@ -114,6 +129,10 @@ except errors.ApiIdInvalidError as exc: await client.disconnect() raise DomainError(503, "TGCLIENT_API_ID/API_HASH rejected by Telegram") from exc + except (Exception, asyncio.TimeoutError) as exc: # noqa: BLE001 + # ConnectionError / OSError / таймаут на полумёртвых сетях — 503, не 500 + await client.disconnect() + raise DomainError(503, f"telegram unreachable: {type(exc).__name__}") from exc login_id = uuid.uuid4().hex expires = _expires_iso() @@ -242,6 +261,16 @@ """Текущий шаг логина (SPA poll / MCP account_login_status).""" db = get_db() row = await _load_login(db, login_id, owner_user_id) + if row["step"] == "awaiting_scan" and login_id not in _qr_tasks: + # рестарт сервиса убил QR-waiter: сканировать некому, токен умер + # (иначе строка ждёт 15 минут, а фронт рисует уже мёртвый QR) + await _cleanup(db, login_id) + raise DomainError(410, "login client lost (service restart) — start again") + if row["step"] in ("awaiting_code", "awaiting_password") \ + and _manager().pending_client(login_id) is None: + # рестарт сервиса убил RAM-клиент: продолжить нечем + await _cleanup(db, login_id, with_client=False) + raise DomainError(410, "login client lost (service restart) — start again") expires_in = max(0, int((datetime.fromisoformat(row["expires_at"]) - _now()).total_seconds())) out = { "login_id": login_id, @@ -334,7 +363,11 @@ raise DomainError(409, "too many pending logins (max 3) — cancel or wait for expiry") client = TelegramClientWithSession(get_settings().api_id, get_settings().api_hash) - await client.connect() + try: + await client.connect() + except (Exception, asyncio.TimeoutError) as exc: # noqa: BLE001 + await client.disconnect() + raise DomainError(503, f"telegram unreachable: {type(exc).__name__}") from exc if not client.is_connected(): await client.disconnect() raise DomainError(503, "cannot connect to telegram") @@ -387,6 +420,8 @@ await asyncio.wait_for(qr.recreate(), timeout=10) except (Exception, asyncio.TimeoutError) as exc: # noqa: BLE001 print(f"[login] qr recreate({login_id}): {type(exc).__name__}", flush=True) + await _mark_terminal(db, login_id, "error", + "qr token refresh failed") return except errors.SessionPasswordNeededError: await db.execute( @@ -398,6 +433,8 @@ return # добивает login_password except errors.FloodWaitError as exc: print(f"[login] qr waiter flood({login_id}): {exc.seconds}s", flush=True) + await _mark_terminal(db, login_id, "error", + f"telegram flood wait: {exc.seconds}s") return except asyncio.CancelledError: return # cancelled by _qr_stop (cancel/cleanup) @@ -406,7 +443,13 @@ row = await cursor.fetchone() if row is None: return # отмена/сгорание прилетели раньше скана - me = await client.get_me() + try: + me = await asyncio.wait_for(client.get_me(), timeout=10) + except (Exception, asyncio.TimeoutError) as exc: # noqa: BLE001 + print(f"[login] qr get_me({login_id}): {type(exc).__name__}", flush=True) + await _mark_terminal(db, login_id, "error", f"finalization failed: " + f"{type(exc).__name__}") + return phone = str(me.phone) if getattr(me, "phone", None) else "" await db.execute( "UPDATE login_sessions SET phone = ? WHERE id = ?", (phone, login_id), @@ -419,6 +462,8 @@ pass except Exception as exc: # noqa: BLE001 — waiter не роняет процесс print(f"[login] qr waiter({login_id}) error: {type(exc).__name__}: {exc}", flush=True) + await _mark_terminal(db, login_id, "error", + f"waiter error: {type(exc).__name__}") finally: _qr_logins.pop(login_id, None) _qr_tasks.pop(login_id, None) @@ -465,13 +510,17 @@ # аккаунт дальше ходит полноценным клиентом пула manager await manager.drop_pending(login_id) await manager.drop(account_id) - # код уже был передан в sign_in (нигде не логируем); phone_code_hash - # вместе со строкой login_sessions удаляется + # код уже был передан в sign_in (нигде не логируем); в терминальной строке + # phone_code_hash стирается (_mark_terminal) report("tg_account_logged_in", { "entity": f"account-{account_id}", "user_id": row["user_id"], "account_id": account_id, "phone_masked": _phone_mask(row["phone"]), "tg_user_id": tg_user_id, }) + # строку НЕ удаляем: 2 минуты в шаге 'done' — SPA-полл QR-входа читает + # настоящий успех (иначе 404 «удалена» трактовался фронтом как успех, + # в том числе при истёкшей/отменённой попытке). Чистит sweeper по expires_at. + await _mark_terminal(db, login_id, "done") class TelegramClientWithSession: @@ -520,22 +569,47 @@ await self._client.disconnect() async def purge_stale_logins() -> int: - """Автоотмена зависших логинов (фоновый sweeper, main.login_sweeper_loop). + """Автоочистка/автоотмена логинов (фоновый sweeper, main.login_sweeper_loop). - Pending-сессия старше login_stale_minutes от created_at = «аккаунт не - создан за N минут» (финализированный логин строки не оставляет) — - отменяется как ручная DELETE: строка в БД + disconnect RAM-клиента - (иначе остаётся зомби-коннект к Telegram). Единственный владелец - очистки login_sessions: auth.purge_expired их больше не удаляет. + 1. Истёкшие строки (в т.ч. терминальные done/error, жившие 2 мин на + чтение поллом) — просто удалить; их RAM-клиентов уже нет. + 2. Неистёкшая pending старше login_stale_minutes от created_at = «аккаунт + не создан за N минут» — отменяется как ручная DELETE: строка в БД + + disconnect RAM-клиента (иначе остаётся зомби-коннект к Telegram). + 3. Строка без RAM-клиента/waiter (рестарт сервиса) — чистится сразу, + не дожидаясь TTL. + + Единственный владелец очистки login_sessions: auth.purge_expired их + больше не удаляет. """ stale_min = get_settings().login_stale_minutes cutoff = (_now() - timedelta(minutes=stale_min)).isoformat() + now = _now().isoformat() db = get_db() + + # 1) истёкшие (терминальные done/error попадают сюда сами по expires_at) + await db.execute("DELETE FROM login_sessions WHERE expires_at < ?", (now,)) + await db.commit() + + # 2)+3) зависшие / осиротевшие (терминальные done/error исключены: у них + # нет клиента, чистит фаза 1 по собственному короткому expires_at) cursor = await db.execute( - "SELECT id, step FROM login_sessions WHERE created_at < ?", (cutoff,) + "SELECT id, step FROM login_sessions WHERE created_at < ? AND expires_at > ?" + " AND step NOT IN ('done', 'error')", + (cutoff, now), ) rows = await cursor.fetchall() + cancelled = 0 for row in rows: + alive = _manager().pending_client(row["id"]) is not None + if row["step"] == "awaiting_scan" and row["id"] not in _qr_tasks: + alive = False # QR-вход без waiter — рестарт, делать нечего + if not alive: + # строка пережила процесс, RAM пуст — чистим без cancelCode + print(f"[login] purge orphan login {row['id']} (step={row['step']}, no client)", + flush=True) + await _cleanup(db, row["id"]) + continue print( f"[login] auto-cancel stale login {row['id']} " f"(age > {stale_min}m, step={row['step']})", @@ -547,4 +621,5 @@ if stale: await _cancel_code(db, stale["id"], stale) await _cleanup(db, row["id"]) - return len(rows) + cancelled += 1 + return cancelled diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4b3490c..11d2021 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,15 @@ "gnexus-synapse @ git+https://git.gnexus.space/git/root/gn-synapse-client-py.git@v0.1.2", ] +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.24", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" + [build-system] requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..c868487 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,11 @@ +import pytest + + +@pytest.fixture(autouse=True) +async def _close_db(): + """Единое aiosqlite-соединение модуля db закрывается в loop теста.""" + yield + import app.db as appdb + + if appdb._conn is not None: + await appdb.close_db() \ No newline at end of file diff --git a/backend/tests/test_login_flow.py b/backend/tests/test_login_flow.py new file mode 100644 index 0000000..b9345f1 --- /dev/null +++ b/backend/tests/test_login_flow.py @@ -0,0 +1,484 @@ +"""Ревью-тесты стейт-машины логина без реального 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() \ No newline at end of file diff --git a/frontend/src/i18n/messages/en.js b/frontend/src/i18n/messages/en.js index cd22975..fd07f63 100644 --- a/frontend/src/i18n/messages/en.js +++ b/frontend/src/i18n/messages/en.js @@ -84,6 +84,7 @@ 'wizard.viaQr': 'Scan QR instead', 'wizard.qrHint': 'Open Telegram → Settings → Devices → “Link desktop device” and scan the QR. No SMS code needed', 'wizard.cancelFailed': 'Failed to cancel the login', + 'wizard.qrFailed': 'QR login aborted — try again', 'wizard.cancel': 'Cancel login', 'wizard.expired': 'Login window expired — start again', diff --git a/frontend/src/i18n/messages/ru.js b/frontend/src/i18n/messages/ru.js index 252e07a..d1ad902 100644 --- a/frontend/src/i18n/messages/ru.js +++ b/frontend/src/i18n/messages/ru.js @@ -84,6 +84,7 @@ 'wizard.viaQr': 'Вход по QR', 'wizard.qrHint': 'Открой Telegram → Настройки → Устройства → «Слинковать устройство» — и отсканируй QR. Код по SMS не нужен', 'wizard.cancelFailed': 'Не удалось отменить логин', + 'wizard.qrFailed': 'QR-вход прервался — попробуй ещё раз', 'wizard.cancel': 'Отменить логин', 'wizard.expired': 'Время логина истекло — начните заново', diff --git a/frontend/src/i18n/messages/uk.js b/frontend/src/i18n/messages/uk.js index 8cf5ecc..3738a68 100644 --- a/frontend/src/i18n/messages/uk.js +++ b/frontend/src/i18n/messages/uk.js @@ -84,6 +84,7 @@ 'wizard.viaQr': 'Вхід за QR', 'wizard.qrHint': 'Відкрий Telegram → Налаштування → Пристрої → «Звʼязати пристрій» — і відскануй QR. Код за SMS не потрібен', 'wizard.cancelFailed': 'Не вдалося скасувати логін', + 'wizard.qrFailed': 'QR-вхід перервався — спробуй ще раз', 'wizard.cancel': 'Скасувати логін', 'wizard.expired': 'Час логіна минув — почніть знову', diff --git a/frontend/src/pages/LoginWizard.vue b/frontend/src/pages/LoginWizard.vue index 42720a2..d5ba0ff 100644 --- a/frontend/src/pages/LoginWizard.vue +++ b/frontend/src/pages/LoginWizard.vue @@ -14,7 +14,7 @@ import { t } from '../i18n/index.js' const toast = useToast() -const open = defineModel({ name: 'open', type: Boolean, default: false }) +const open = defineModel('open', { type: Boolean, default: false }) const emit = defineEmits(['done']) @@ -28,6 +28,7 @@ const expiresAt = ref(null) const attemptsLeft = ref(null) const submitting = ref(false) +const cancelling = ref(false) const polling = ref(false) const qrImage = ref('') // dataURL QR-кода @@ -39,7 +40,9 @@ function startPolling() { stopPolling() pollTimer = setInterval(async () => { - if (!polling.value) return + // polling = «сейчас идёт запрос» — на мёртвых сетях тик пропускается; + // вне активных шагов (phone/done) поллинг не нужен + if (polling.value || step.value === 'phone' || step.value === 'done') return try { // QR-шаг опрашивает QR-статус, остальные — код/пароль-статус if (step.value === 'qr') await refreshQrState() @@ -71,11 +74,17 @@ } watch(open, (isOpen) => { + console.log('[wiz] watch open ->', isOpen, 'loginId:', loginId.value, 'step:', step.value) if (isOpen) { reset() } else { stopPolling() stopCountdown() + // закрыли без успеха (X/ESC/клик мимо) — не оставляем живую попытку логина: + // Telegram auth.cancelCode + очистка строки (как кнопка «Отменить логин») + if (loginId.value && !['done', 'phone'].includes(step.value)) { + api.cancelLogin(loginId.value).catch(() => {}) + } } }) @@ -92,6 +101,7 @@ qrImage.value = '' submitting.value = false polling.value = false + cancelling.value = false stopPolling() stopCountdown() } @@ -136,25 +146,52 @@ polling.value = true try { const state = await api.loginState(loginId.value) + if (state.step === 'done') { + finish() + return + } + if (state.step === 'error') { + stopPolling() + toast.danger({ title: t('wizard.expired'), text: state.last_error || '' }) + reset() + return + } startCountdown(state.expires_in_sec) phoneMasked.value = state.phone_masked || phoneMasked.value if (state.step === 'awaiting_code' || state.step === 'awaiting_password') { step.value = state.step === 'awaiting_password' ? 'password' : 'code' - if (state.step === 'awaiting_password' && step.value === 'password') { + if (state.step === 'awaiting_password') { stopPolling() } } + } catch (e) { + if (e.status === 404 || e.status === 410) { + // логин отменили/истёк извне (другая вкладка, sweeper, рестарт) + stopPolling() + toast.danger({ title: t('wizard.expired') }) + reset() + return + } } finally { polling.value = false } } -// poll QR-шага: скан принят (bэкенд финализирует сам) → done; 2FA → password; -// токен умер — бэкенд сам обновил qr_url, перерисовываем QR +// poll QR-шага: done (скан принят) → success; error → тост + заново; 2FA → password async function refreshQrState() { polling.value = true try { const state = await api.qrLoginState(loginId.value) + if (state.step === 'done') { + finish() + return + } + if (state.step === 'error') { + stopPolling() + toast.danger({ title: t('wizard.qrFailed'), text: state.last_error || '' }) + reset() + return + } startCountdown(state.expires_in_sec) if (state.step === 'awaiting_scan' && state.qr_url) { const fresh = await QRCode.toDataURL(state.qr_url, { @@ -170,15 +207,19 @@ step.value = 'code' startPolling() return + } else if (state.step === 'awaiting_scan') { + // qr_url потерян (waiter умер) — ждать нечего + stopPolling() + toast.danger({ title: t('wizard.qrFailed') }) + reset() + return } } catch (e) { if (e.status === 404 || e.status === 410) { - // скан принят и финализирован (строка удалена) либо сгорела + // логин отменили/истёк извне — НЕ успех: честный тост об отмене stopPolling() - toast.success({ title: t('wizard.success') }) - step.value = 'done' - emit('done') - setTimeout(() => { open.value = false }, 800) + toast.danger({ title: t('wizard.expired') }) + reset() return } } finally { @@ -256,15 +297,21 @@ // «Отменить логин»: бэкенд сам шлёт Telegram auth.cancelCode (инвалидация // кода) и чистит сессию; 404/410 — попытка уже отменилась сама, не страшно. if (loginId.value && step.value !== 'phone') { + cancelling.value = true try { await api.cancelLogin(loginId.value) + loginId.value = '' // закрываем сами: не даём watch повторно отменять } catch (e) { if (e.status === 404 || e.status === 410) { // уже отменёна/истекла — просто закрываем + loginId.value = '' } else { toast.danger({ title: t('wizard.cancelFailed'), text: e.message }) + cancelling.value = false return // модалку не закрываем: попытка ещё жива, можно повторить } + } finally { + cancelling.value = false } } open.value = false @@ -324,7 +371,7 @@ {{ t('wizard.qrHint') }}

- {{ t('wizard.cancel') }} + {{ t('wizard.cancel') }}
@@ -339,7 +386,7 @@ @keydown.enter="submitCode" />
- {{ t('wizard.cancel') }} + {{ t('wizard.cancel') }}
- {{ t('wizard.cancel') }} + {{ t('wizard.cancel') }}