Spaces:
Running
Running
| """E2E-ish tests covering the new fullstack-parity wiring. | |
| These tests don't require Docker / Postgres — they: | |
| * exercise i18n keys for completeness, | |
| * exercise the initData HMAC verifier (positive + negative cases), | |
| * drive the gatekeeper handler with a stubbed bot + in-memory DB, | |
| * spin up the real aiohttp app with stub bot+db and hit /api/me, /api/stats. | |
| To run: | |
| BOT_TOKEN=test:abc WEBHOOK_BASE_URL=https://example.com \\ | |
| WEBHOOK_SECRET=secret DATABASE_URL=postgres://x \\ | |
| ADMIN_USER_IDS=111 \\ | |
| .venv/Scripts/python.exe -m pytest tests/test_fullstack_parity.py -v | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import hashlib | |
| import hmac | |
| import json | |
| import os | |
| import time | |
| from dataclasses import dataclass, field | |
| from typing import Any, Dict, List, Optional | |
| import pytest | |
| import pytest_asyncio | |
| # --- env so config.load_settings() doesn't blow up at import time ----------- | |
| os.environ.setdefault("BOT_TOKEN", "test:abc") | |
| os.environ.setdefault("WEBHOOK_BASE_URL", "https://example.com") | |
| os.environ.setdefault("WEBHOOK_SECRET", "secret") | |
| os.environ.setdefault("DATABASE_URL", "postgres://x") | |
| os.environ.setdefault("ADMIN_USER_IDS", "111") | |
| from app.config import load_settings # noqa: E402 | |
| from app.i18n import LOCALES, t, infer_lang_from_telegram # noqa: E402 | |
| from app.webapp import _verify_init_data, setup_webapp # noqa: E402 | |
| # ---------------------------------------------------------------------------- | |
| # i18n: every key in EN must exist in RU and AZ | |
| # ---------------------------------------------------------------------------- | |
| def test_i18n_locale_completeness(): | |
| en_keys = set(LOCALES["en"].keys()) | |
| ru_keys = set(LOCALES["ru"].keys()) | |
| az_keys = set(LOCALES["az"].keys()) | |
| missing_ru = en_keys - ru_keys | |
| missing_az = en_keys - az_keys | |
| assert not missing_ru, f"RU missing keys: {missing_ru}" | |
| assert not missing_az, f"AZ missing keys: {missing_az}" | |
| def test_i18n_inference(): | |
| assert infer_lang_from_telegram("ru") == "ru" | |
| assert infer_lang_from_telegram("ru-RU") == "ru" | |
| assert infer_lang_from_telegram("az-Latn") == "az" | |
| assert infer_lang_from_telegram("en-US") == "en" | |
| assert infer_lang_from_telegram("fr") == "en" | |
| assert infer_lang_from_telegram(None) == "en" | |
| def test_i18n_format_safety(): | |
| """Missing variables in templates fall back to the raw template, not crash.""" | |
| out = t("ru", "gk_owner_dm_body", title="x") | |
| assert "{added_by}" in out or "x" in out # didn't raise | |
| # ---------------------------------------------------------------------------- | |
| # initData verifier | |
| # ---------------------------------------------------------------------------- | |
| def _signed_init_data(token: str, *, age: int = 0, override_user: dict | None = None) -> str: | |
| auth_date = int(time.time()) - age | |
| user_obj = override_user or {"id": 42, "first_name": "A", "username": "a"} | |
| pairs = { | |
| "auth_date": str(auth_date), | |
| "query_id": "AAH", | |
| "user": json.dumps(user_obj), | |
| } | |
| data_check = "\n".join(f"{k}={pairs[k]}" for k in sorted(pairs)) | |
| secret = hmac.new(b"WebAppData", token.encode(), hashlib.sha256).digest() | |
| h = hmac.new(secret, data_check.encode(), hashlib.sha256).hexdigest() | |
| return "&".join(f"{k}={v}" for k, v in pairs.items()) + f"&hash={h}" | |
| def test_init_data_valid(): | |
| raw = _signed_init_data("test:abc") | |
| parsed = _verify_init_data(raw, "test:abc") | |
| assert parsed is not None | |
| assert parsed["user"]["id"] == 42 | |
| def test_init_data_wrong_token(): | |
| raw = _signed_init_data("test:abc") | |
| assert _verify_init_data(raw, "different:token") is None | |
| def test_init_data_tampered_hash(): | |
| raw = _signed_init_data("test:abc") | |
| # Replace the hash with all zeros | |
| parts = raw.split("&hash=") | |
| tampered = parts[0] + "&hash=" + ("0" * 64) | |
| assert _verify_init_data(tampered, "test:abc") is None | |
| def test_init_data_expired(): | |
| raw = _signed_init_data("test:abc", age=2 * 24 * 3600) | |
| assert _verify_init_data(raw, "test:abc") is None | |
| def test_init_data_empty(): | |
| assert _verify_init_data("", "test:abc") is None | |
| assert _verify_init_data(None, "test:abc") is None # type: ignore[arg-type] | |
| # ---------------------------------------------------------------------------- | |
| # Stub DB + Bot for gatekeeper / API integration | |
| # ---------------------------------------------------------------------------- | |
| class StubChat: | |
| chat_id: int | |
| title: str = "Test Chat" | |
| added_by: Optional[int] = None | |
| status: str = "pending" | |
| status_at: Any = None | |
| status_by: Optional[int] = None | |
| member_count: Optional[int] = None | |
| added_at: Any = field(default_factory=lambda: __import__("datetime").datetime.utcnow()) | |
| class StubDB: | |
| def __init__(self): | |
| self.chats: Dict[int, StubChat] = {} | |
| self.user_lang: Dict[int, str] = {} | |
| self.events: List[Dict[str, Any]] = [] | |
| self.settings: Dict[int, Dict[str, Any]] = {} | |
| self.connected = True | |
| async def upsert_chat(self, chat_id, title, added_by, member_count=None, initial_status="pending"): | |
| existing = self.chats.get(chat_id) | |
| if existing: | |
| if existing.status not in ("approved", "rejected"): | |
| existing.status = "pending" | |
| existing.title = title | |
| return existing.status | |
| c = StubChat(chat_id=chat_id, title=title, added_by=added_by, | |
| member_count=member_count, status=initial_status) | |
| self.chats[chat_id] = c | |
| return c.status | |
| async def set_chat_status(self, chat_id, status, decided_by=None): | |
| c = self.chats.get(chat_id) | |
| if c: | |
| c.status = status | |
| c.status_by = decided_by | |
| async def get_chat(self, chat_id): | |
| c = self.chats.get(chat_id) | |
| if not c: | |
| return None | |
| return { | |
| "chat_id": c.chat_id, "title": c.title, "added_by": c.added_by, | |
| "status": c.status, "status_at": c.status_at, "status_by": c.status_by, | |
| "member_count": c.member_count, "added_at": c.added_at, | |
| } | |
| async def is_chat_approved(self, chat_id): | |
| c = self.chats.get(chat_id) | |
| return bool(c and c.status == "approved") | |
| async def list_chats_for_admin(self, _admin_user_id): | |
| return [ | |
| {"chat_id": c.chat_id, "title": c.title, "status": c.status, | |
| "member_count": c.member_count, "added_at": c.added_at, "status_at": c.status_at} | |
| for c in self.chats.values() if c.status == "approved" | |
| ] | |
| async def mark_chat_removed(self, chat_id): | |
| await self.set_chat_status(chat_id, "removed") | |
| async def delete_chat(self, chat_id): | |
| self.chats.pop(chat_id, None) | |
| async def get_user_lang(self, user_id): | |
| return self.user_lang.get(user_id, "en") | |
| async def set_user_lang(self, user_id, lang): | |
| self.user_lang[user_id] = lang | |
| async def stats_for_chat(self, chat_id): | |
| return { | |
| "d24": 1, "week": 5, "week_delta_pct": 50, "total": 10, | |
| "by_category": [{"key": "porn", "count": 3}], | |
| "daily": [{"day": "2026-05-29", "count": 1}], | |
| "top_violators": [{"user_id": 7, "name": "X", "count": 3}], | |
| } | |
| async def events_for_chat(self, chat_id, kinds=None, limit=100): | |
| return list(self.events) | |
| async def get_settings(self, chat_id): | |
| from app.database import ChatSettings | |
| return ChatSettings(chat_id=chat_id) | |
| async def update_setting(self, chat_id, key, value): | |
| self.settings.setdefault(chat_id, {})[key] = value | |
| async def record_event(self, **kw): | |
| self.events.append(kw) | |
| class StubBot: | |
| """Mimics aiogram Bot calls used by the webapp module.""" | |
| def __init__(self): | |
| self.sent: List[Dict[str, Any]] = [] | |
| self.left: List[int] = [] | |
| self.member_admin_for: Dict[tuple[int, int], bool] = {} | |
| async def send_message(self, chat_id, text, **kw): | |
| self.sent.append({"chat_id": chat_id, "text": text, **kw}) | |
| return type("Msg", (), {"message_id": 1})() | |
| async def leave_chat(self, chat_id): | |
| self.left.append(chat_id) | |
| return True | |
| async def get_chat_member_count(self, chat_id): | |
| return 7 | |
| async def get_chat_member(self, chat_id, user_id): | |
| is_admin = self.member_admin_for.get((chat_id, user_id), False) | |
| from aiogram.enums import ChatMemberStatus | |
| cls = type("M", (), {"status": ( | |
| ChatMemberStatus.ADMINISTRATOR if is_admin else ChatMemberStatus.MEMBER | |
| )}) | |
| return cls() | |
| # ---------------------------------------------------------------------------- | |
| # Gatekeeper logic with stub DB | |
| # ---------------------------------------------------------------------------- | |
| async def test_gatekeeper_owner_self_add_auto_approves(): | |
| db = StubDB() | |
| settings = load_settings() | |
| owner = settings.owner_id | |
| assert owner is not None | |
| # Random user adds → pending | |
| status_random = await db.upsert_chat( | |
| chat_id=-100, title="random", added_by=999, | |
| member_count=5, initial_status="pending", | |
| ) | |
| assert status_random == "pending" | |
| # Owner adds another chat → approved | |
| status_owner = await db.upsert_chat( | |
| chat_id=-200, title="owner", added_by=owner, | |
| member_count=5, initial_status="approved", | |
| ) | |
| assert status_owner == "approved" | |
| async def test_gatekeeper_re_add_keeps_decision(): | |
| db = StubDB() | |
| # Initial: pending | |
| await db.upsert_chat(chat_id=-100, title="t", added_by=1, initial_status="pending") | |
| # Owner approves | |
| await db.set_chat_status(-100, "approved", decided_by=111) | |
| # Re-add by random — must keep 'approved' | |
| again = await db.upsert_chat(chat_id=-100, title="t", added_by=2, initial_status="pending") | |
| assert again == "approved" | |
| async def test_gatekeeper_blocks_moderation(): | |
| db = StubDB() | |
| await db.upsert_chat(chat_id=-100, title="t", added_by=1, initial_status="pending") | |
| assert (await db.is_chat_approved(-100)) is False | |
| await db.set_chat_status(-100, "approved") | |
| assert (await db.is_chat_approved(-100)) is True | |
| # ---------------------------------------------------------------------------- | |
| # aiohttp app: hit /api/me with valid + invalid initData | |
| # ---------------------------------------------------------------------------- | |
| async def http_app(aiohttp_client): | |
| from aiohttp import web | |
| app = web.Application() | |
| db = StubDB() | |
| bot = StubBot() | |
| settings = load_settings() | |
| setup_webapp(app, bot=bot, db=db, settings=settings) | |
| client = await aiohttp_client(app) | |
| return client, db, bot, settings | |
| async def test_api_me_valid(http_app): | |
| client, db, bot, settings = http_app | |
| raw = _signed_init_data(settings.bot_token, override_user={"id": 5, "first_name": "U", "language_code": "ru"}) | |
| r = await client.get("/api/me", headers={"X-Telegram-Init-Data": raw}) | |
| assert r.status == 200 | |
| body = await r.json() | |
| assert body["id"] == 5 | |
| assert body["lang"] == "ru" | |
| assert body["is_owner"] is False | |
| async def test_api_me_unauthorized(http_app): | |
| client, *_ = http_app | |
| r = await client.get("/api/me", headers={"X-Telegram-Init-Data": "garbage"}) | |
| assert r.status == 401 | |
| async def test_api_chat_admin_required(http_app): | |
| client, db, bot, settings = http_app | |
| raw = _signed_init_data(settings.bot_token, override_user={"id": 5, "first_name": "U"}) | |
| # Chat exists and is approved, but user 5 is not admin | |
| await db.upsert_chat(-100, "t", added_by=1, initial_status="approved") | |
| r = await client.get("/api/stats/-100", headers={"X-Telegram-Init-Data": raw}) | |
| assert r.status == 403 | |
| body = await r.json() | |
| assert body["error"]["code"] == "not_admin" | |
| # Now make the user an admin (clear the in-process TTL cache so the new | |
| # value is honored without waiting 60s). | |
| from app import webapp as _wm | |
| _wm._admin_cache.clear() | |
| bot.member_admin_for[(-100, 5)] = True | |
| r2 = await client.get("/api/stats/-100", headers={"X-Telegram-Init-Data": raw}) | |
| assert r2.status == 200 | |
| s = await r2.json() | |
| assert s["d24"] == 1 | |
| assert s["total"] == 10 | |
| async def test_api_static_app_present(http_app): | |
| client, *_ = http_app | |
| r = await client.get("/app/index.html") | |
| # Either the index is served (200) or the static dir exists with the file we need. | |
| assert r.status in (200, 404), "static route must respond" | |
| async def test_api_patch_settings_writes_event(http_app): | |
| client, db, bot, settings = http_app | |
| raw = _signed_init_data(settings.bot_token, override_user={"id": 7, "first_name": "U"}) | |
| await db.upsert_chat(-100, "t", added_by=1, initial_status="approved") | |
| bot.member_admin_for[(-100, 7)] = True | |
| r = await client.patch( | |
| "/api/settings/-100", | |
| headers={"X-Telegram-Init-Data": raw, "Content-Type": "application/json"}, | |
| data=json.dumps({"threshold": 85, "block_sexy": True, "garbage_key": "ignored"}), | |
| ) | |
| assert r.status == 200, await r.text() | |
| body = await r.json() | |
| assert body["applied"]["threshold"] == 85 | |
| assert body["applied"]["block_sexy"] is True | |
| assert "garbage_key" not in body["applied"] | |
| # Audit event recorded | |
| assert any(e.get("kind") == "settings_changed" for e in db.events) | |
| async def test_api_patch_antispam_keys_accepted(http_app): | |
| """Regression: the frontend used to send local UI keys (antispam_warn, | |
| violation_window, ban_duration, delete_warning_seconds) that the backend | |
| silently dropped. After the frontend key-map fix it sends the real column | |
| names — assert the backend persists them.""" | |
| client, db, bot, settings = http_app | |
| raw = _signed_init_data(settings.bot_token, override_user={"id": 9, "first_name": "U"}) | |
| await db.upsert_chat(-100, "t", added_by=1, initial_status="approved") | |
| bot.member_admin_for[(-100, 9)] = True | |
| payload = { | |
| "warn_user": False, | |
| "violation_window_minutes": 360, | |
| "ban_duration_minutes": 0, # frontend maps "forever" → 0 | |
| "warn_delete_seconds": 60, | |
| } | |
| r = await client.patch( | |
| "/api/settings/-100", | |
| headers={"X-Telegram-Init-Data": raw, "Content-Type": "application/json"}, | |
| data=json.dumps(payload), | |
| ) | |
| assert r.status == 200, await r.text() | |
| applied = (await r.json())["applied"] | |
| for k, v in payload.items(): | |
| assert applied.get(k) == v, f"{k} not applied (got {applied.get(k)})" | |