lutpetuxbot / tests /test_fullstack_parity.py
lutpetux-deploy
Deploy: Lüt Petux Remover bot + mini-app (gatekeeper, i18n, stats API)
40e6ccb
Raw
History Blame Contribute Delete
14.7 kB
"""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
# ----------------------------------------------------------------------------
@dataclass
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
# ----------------------------------------------------------------------------
@pytest.mark.asyncio
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"
@pytest.mark.asyncio
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"
@pytest.mark.asyncio
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
# ----------------------------------------------------------------------------
@pytest_asyncio.fixture
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
@pytest.mark.asyncio
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
@pytest.mark.asyncio
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
@pytest.mark.asyncio
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
@pytest.mark.asyncio
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"
@pytest.mark.asyncio
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)
@pytest.mark.asyncio
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)})"