kin: BATON-008 На главной странице под логином сделать кнопку модулем регистрации - указать почту, логин и пароль, нажать зарегистрироваться. После этого сообщение о регистрации приходит в чат администратору 5694335584 и кнопка апрув или не апрув, если апрув то отправителя улетает пуш на pwa что он зарегистрирован, если отказ то ничего не происходит
This commit is contained in:
parent
8c4c46ee92
commit
40e1a9fa48
2 changed files with 304 additions and 0 deletions
|
|
@ -133,6 +133,7 @@ async def health() -> dict[str, Any]:
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/vapid-public-key")
|
@app.get("/api/vapid-public-key")
|
||||||
|
@app.get("/api/push/public-key")
|
||||||
async def vapid_public_key() -> dict[str, str]:
|
async def vapid_public_key() -> dict[str, str]:
|
||||||
return {"vapid_public_key": config.VAPID_PUBLIC_KEY}
|
return {"vapid_public_key": config.VAPID_PUBLIC_KEY}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)}"
|
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(salt_hex, 16) # raises ValueError if not valid hex
|
||||||
int(dk_hex, 16)
|
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()}"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue