From 40e1a9fa48863a7dce43447f6ee13ec80ab9ef9a Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 12:36:07 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20BATON-008=20=D0=9D=D0=B0=20=D0=B3=D0=BB?= =?UTF-8?q?=D0=B0=D0=B2=D0=BD=D0=BE=D0=B9=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D0=B5=20=D0=BF=D0=BE=D0=B4=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=BC=20=D1=81=D0=B4=D0=B5=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D1=83=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D1=83=D0=BB=D0=B5=D0=BC=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20-=20=D1=83=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BE=D1=87=D1=82=D1=83,=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=D0=BD=20=D0=B8=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C,=20=D0=BD=D0=B0=D0=B6=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B7=D0=B0=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=D1=81=D1=8F.=20=D0=9F?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20=D1=8D=D1=82=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B8=D1=85=D0=BE=D0=B4=D0=B8=D1=82=20?= =?UTF-8?q?=D0=B2=20=D1=87=D0=B0=D1=82=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=82=D0=BE=D1=80=D1=83=20569433?= =?UTF-8?q?5584=20=D0=B8=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D1=83=D0=B2=20=D0=B8=D0=BB=D0=B8=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D1=83=D0=B2,=20=D0=B5=D1=81=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D1=83=D0=B2=20=D1=82=D0=BE=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D0=B5=D0=BB=D1=8F=20=D1=83?= =?UTF-8?q?=D0=BB=D0=B5=D1=82=D0=B0=D0=B5=D1=82=20=D0=BF=D1=83=D1=88=20?= =?UTF-8?q?=D0=BD=D0=B0=20pwa=20=D1=87=D1=82=D0=BE=20=D0=BE=D0=BD=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD,=20=D0=B5=D1=81=D0=BB=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B0=D0=B7=20=D1=82=D0=BE=20=D0=BD=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=20=D0=BD=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B8?= =?UTF-8?q?=D1=81=D1=85=D0=BE=D0=B4=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 1 + tests/test_baton_008.py | 303 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) diff --git a/backend/main.py b/backend/main.py index ebd2f07..ce6f4ea 100644 --- a/backend/main.py +++ b/backend/main.py @@ -133,6 +133,7 @@ async def health() -> dict[str, Any]: @app.get("/api/vapid-public-key") +@app.get("/api/push/public-key") async def vapid_public_key() -> dict[str, str]: return {"vapid_public_key": config.VAPID_PUBLIC_KEY} diff --git a/tests/test_baton_008.py b/tests/test_baton_008.py index f572cd8..b1e0c03 100644 --- a/tests/test_baton_008.py +++ b/tests/test_baton_008.py @@ -583,3 +583,306 @@ async def test_password_hash_stored_in_pbkdf2_format(): assert len(dk_hex) == 64, f"Expected 64-char dk hex (SHA-256), got {len(dk_hex)}" int(salt_hex, 16) # raises ValueError if not valid hex int(dk_hex, 16) + + +# --------------------------------------------------------------------------- +# 15. State machine — повторное нажатие approve на уже approved +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_webhook_callback_double_approve_does_not_send_push(): + """Second approve on already-approved registration must NOT fire push.""" + push_sub = { + "endpoint": "https://fcm.googleapis.com/fcm/send/test2", + "keys": {"p256dh": "BQDEF", "auth": "abc"}, + } + async with make_app_client() as client: + with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): + reg_resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "email": "double@example.com", "login": "doubleuser", "push_subscription": push_sub}, + ) + assert reg_resp.status_code == 201 + + from backend import config as _cfg + import aiosqlite + async with aiosqlite.connect(_cfg.DB_PATH) as conn: + conn.row_factory = aiosqlite.Row + async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: + row = await cur.fetchone() + reg_id = row["id"] if row else None + assert reg_id is not None + + cb_payload = { + "callback_query": { + "id": "cq_d1", + "data": f"approve:{reg_id}", + "message": {"message_id": 60, "chat": {"id": 5694335584}}, + } + } + + # First approve — should succeed + await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS) + await asyncio.sleep(0) + + # Second approve — push must NOT be fired + push_calls: list = [] + + async def _capture_push(sub_json, title, body): + push_calls.append(sub_json) + + cb_payload2 = {**cb_payload, "callback_query": {**cb_payload["callback_query"], "id": "cq_d2"}} + with patch("backend.push.send_push", side_effect=_capture_push): + await client.post("/api/webhook/telegram", json=cb_payload2, headers=_WEBHOOK_HEADERS) + await asyncio.sleep(0) + + assert len(push_calls) == 0, f"Second approve must not fire push, got {len(push_calls)} calls" + + # Also verify status is still 'approved' + from backend import db as _db + # Can't check here as client context is closed; DB assertion was covered by state machine logic + + +@pytest.mark.asyncio +async def test_webhook_callback_double_approve_status_stays_approved(): + """Status remains 'approved' after a second approve callback.""" + from backend import db as _db + + async with make_app_client() as client: + with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): + reg_resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "email": "stay@example.com", "login": "stayuser"}, + ) + assert reg_resp.status_code == 201 + + from backend import config as _cfg + import aiosqlite + async with aiosqlite.connect(_cfg.DB_PATH) as conn: + conn.row_factory = aiosqlite.Row + async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: + row = await cur.fetchone() + reg_id = row["id"] if row else None + assert reg_id is not None + + cb = { + "callback_query": { + "id": "cq_s1", + "data": f"approve:{reg_id}", + "message": {"message_id": 70, "chat": {"id": 5694335584}}, + } + } + await client.post("/api/webhook/telegram", json=cb, headers=_WEBHOOK_HEADERS) + await asyncio.sleep(0) + + cb2 = {**cb, "callback_query": {**cb["callback_query"], "id": "cq_s2"}} + await client.post("/api/webhook/telegram", json=cb2, headers=_WEBHOOK_HEADERS) + await asyncio.sleep(0) + + reg = await _db.get_registration(reg_id) + assert reg["status"] == "approved", f"Expected 'approved', got {reg['status']!r}" + + +# --------------------------------------------------------------------------- +# 16. State machine — approve после reject +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_webhook_callback_approve_after_reject_status_stays_rejected(): + """Approve after reject must NOT change status — remains 'rejected'.""" + from backend import db as _db + + async with make_app_client() as client: + with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): + reg_resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "email": "artest@example.com", "login": "artestuser"}, + ) + assert reg_resp.status_code == 201 + + from backend import config as _cfg + import aiosqlite + async with aiosqlite.connect(_cfg.DB_PATH) as conn: + conn.row_factory = aiosqlite.Row + async with conn.execute("SELECT id FROM registrations LIMIT 1") as cur: + row = await cur.fetchone() + reg_id = row["id"] if row else None + assert reg_id is not None + + # First: reject + rej_cb = { + "callback_query": { + "id": "cq_ar1", + "data": f"reject:{reg_id}", + "message": {"message_id": 80, "chat": {"id": 5694335584}}, + } + } + await client.post("/api/webhook/telegram", json=rej_cb, headers=_WEBHOOK_HEADERS) + await asyncio.sleep(0) + + # Then: approve — must be ignored + push_calls: list = [] + + async def _capture_push(sub_json, title, body): + push_calls.append(sub_json) + + app_cb = { + "callback_query": { + "id": "cq_ar2", + "data": f"approve:{reg_id}", + "message": {"message_id": 81, "chat": {"id": 5694335584}}, + } + } + with patch("backend.push.send_push", side_effect=_capture_push): + await client.post("/api/webhook/telegram", json=app_cb, headers=_WEBHOOK_HEADERS) + await asyncio.sleep(0) + + reg = await _db.get_registration(reg_id) + assert reg["status"] == "rejected", f"Expected 'rejected', got {reg['status']!r}" + + assert len(push_calls) == 0, f"Approve after reject must not fire push, got {len(push_calls)}" + + +# --------------------------------------------------------------------------- +# 17. Rate limiting — 4th request returns 429 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auth_register_rate_limit_fourth_request_returns_429(): + """4th registration request from same IP within the window returns 429.""" + async with make_app_client() as client: + with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock): + for i in range(3): + r = await client.post( + "/api/auth/register", + json={ + "email": f"ratetest{i}@example.com", + "login": f"ratetest{i}", + "password": "strongpassword123", + }, + ) + assert r.status_code == 201, f"Request {i+1} should succeed, got {r.status_code}" + + # 4th request — must be rate-limited + r4 = await client.post( + "/api/auth/register", + json={ + "email": "ratetest4@example.com", + "login": "ratetest4", + "password": "strongpassword123", + }, + ) + + assert r4.status_code == 429, f"Expected 429 on 4th request, got {r4.status_code}" + + +# --------------------------------------------------------------------------- +# 18. VAPID public key endpoint /api/push/public-key +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_vapid_public_key_new_endpoint_returns_200(): + """GET /api/push/public-key returns 200 with vapid_public_key field.""" + async with make_app_client() as client: + resp = await client.get("/api/push/public-key") + + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}" + body = resp.json() + assert "vapid_public_key" in body, f"Expected 'vapid_public_key' in response, got {body}" + + +# --------------------------------------------------------------------------- +# 19. Password max length — 129 chars → 422 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auth_register_422_password_too_long(): + """Password of 129 characters returns 422.""" + async with make_app_client() as client: + resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "password": "a" * 129}, + ) + assert resp.status_code == 422, f"Expected 422 on 129-char password, got {resp.status_code}" + + +# --------------------------------------------------------------------------- +# 20. Login max length — 31 chars → 422 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auth_register_422_login_too_long(): + """Login of 31 characters returns 422.""" + async with make_app_client() as client: + resp = await client.post( + "/api/auth/register", + json={**_VALID_PAYLOAD, "login": "a" * 31}, + ) + assert resp.status_code == 422, f"Expected 422 on 31-char login, got {resp.status_code}" + + +# --------------------------------------------------------------------------- +# 21. Empty body — POST /api/auth/register with {} → 422 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_auth_register_422_empty_body(): + """Empty JSON body returns 422.""" + async with make_app_client() as client: + resp = await client.post("/api/auth/register", json={}) + assert resp.status_code == 422, f"Expected 422 on empty body, got {resp.status_code}" + + +# --------------------------------------------------------------------------- +# 22. Malformed callback_data — no colon → ok:True without crash +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_webhook_callback_malformed_data_no_colon_returns_ok(): + """callback_query with data='garbage' (no colon) returns ok:True gracefully.""" + async with make_app_client() as client: + cb_payload = { + "callback_query": { + "id": "cq_mal1", + "data": "garbage", + "message": {"message_id": 90, "chat": {"id": 5694335584}}, + } + } + resp = await client.post( + "/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS + ) + + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + assert resp.json() == {"ok": True}, f"Expected ok:True, got {resp.json()}" + + +# --------------------------------------------------------------------------- +# 23. Non-numeric reg_id — data='approve:abc' → ok:True without crash +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_webhook_callback_non_numeric_reg_id_returns_ok(): + """callback_query with data='approve:abc' (non-numeric reg_id) returns ok:True.""" + async with make_app_client() as client: + cb_payload = { + "callback_query": { + "id": "cq_nan1", + "data": "approve:abc", + "message": {"message_id": 91, "chat": {"id": 5694335584}}, + } + } + resp = await client.post( + "/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS + ) + + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}" + assert resp.json() == {"ok": True}, f"Expected ok:True, got {resp.json()}"