kin: BATON-FIX-008 [TECH DEBT] Серверный код (backend/main.py, middleware.py) расходится с worktree — у сервера нет rate_limit_signal в middleware, серверный main.py пропатчен вручную через sed
This commit is contained in:
parent
177a0d80dd
commit
370a2157b9
2 changed files with 461 additions and 0 deletions
|
|
@ -347,3 +347,235 @@ async def test_webhook_callback_unknown_reg_id_returns_ok():
|
|||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Registration without push_subscription
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_without_push_subscription():
|
||||
"""Registration with push_subscription=null returns 201."""
|
||||
async with make_app_client() as client:
|
||||
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||
resp = await client.post(
|
||||
"/api/auth/register",
|
||||
json={**_VALID_PAYLOAD, "email": "nopush@example.com", "login": "nopushuser"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["status"] == "pending"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. reject does NOT trigger Web Push
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_callback_reject_does_not_send_push():
|
||||
"""reject callback does NOT call send_push."""
|
||||
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)
|
||||
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_r001",
|
||||
"data": f"reject:{reg_id}",
|
||||
"message": {"message_id": 50, "chat": {"id": 5694335584}},
|
||||
}
|
||||
}
|
||||
push_calls: list = []
|
||||
|
||||
async def _capture_push(sub_json, title, body):
|
||||
push_calls.append(sub_json)
|
||||
|
||||
with patch("backend.push.send_push", side_effect=_capture_push):
|
||||
await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert len(push_calls) == 0, f"Expected 0 push calls on reject, got {len(push_calls)}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. approve calls editMessageText with ✅ text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_callback_approve_edits_message():
|
||||
"""approve callback calls editMessageText with '✅ Пользователь ... одобрен'."""
|
||||
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": "edit@example.com", "login": "edituser"},
|
||||
)
|
||||
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_e001",
|
||||
"data": f"approve:{reg_id}",
|
||||
"message": {"message_id": 51, "chat": {"id": 5694335584}},
|
||||
}
|
||||
}
|
||||
edit_calls: list[str] = []
|
||||
|
||||
async def _capture_edit(chat_id, message_id, text):
|
||||
edit_calls.append(text)
|
||||
|
||||
with patch("backend.telegram.edit_message_text", side_effect=_capture_edit):
|
||||
await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS)
|
||||
|
||||
assert len(edit_calls) == 1, f"Expected 1 editMessageText call, got {len(edit_calls)}"
|
||||
assert "✅" in edit_calls[0], f"Expected ✅ in edit text, got: {edit_calls[0]!r}"
|
||||
assert "edituser" in edit_calls[0], f"Expected login in edit text, got: {edit_calls[0]!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. answerCallbackQuery is called after callback processing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_callback_answer_sent():
|
||||
"""answerCallbackQuery is called with the callback_query_id after processing."""
|
||||
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": "answer@example.com", "login": "answeruser"},
|
||||
)
|
||||
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_a001",
|
||||
"data": f"approve:{reg_id}",
|
||||
"message": {"message_id": 52, "chat": {"id": 5694335584}},
|
||||
}
|
||||
}
|
||||
answer_calls: list[str] = []
|
||||
|
||||
async def _capture_answer(callback_query_id):
|
||||
answer_calls.append(callback_query_id)
|
||||
|
||||
with patch("backend.telegram.answer_callback_query", side_effect=_capture_answer):
|
||||
await client.post("/api/webhook/telegram", json=cb_payload, headers=_WEBHOOK_HEADERS)
|
||||
|
||||
assert len(answer_calls) == 1, f"Expected 1 answerCallbackQuery call, got {len(answer_calls)}"
|
||||
assert answer_calls[0] == "cq_a001"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12. CORS — Authorization header is allowed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cors_authorization_header_allowed():
|
||||
"""CORS preflight request allows Authorization header."""
|
||||
async with make_app_client() as client:
|
||||
resp = await client.options(
|
||||
"/api/auth/register",
|
||||
headers={
|
||||
"Origin": "http://localhost:3000",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "Authorization",
|
||||
},
|
||||
)
|
||||
assert resp.status_code in (200, 204), f"CORS preflight returned {resp.status_code}"
|
||||
allow_headers = resp.headers.get("access-control-allow-headers", "")
|
||||
assert "authorization" in allow_headers.lower(), (
|
||||
f"Authorization not in Access-Control-Allow-Headers: {allow_headers!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 13. DB — registrations table exists after init_db
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registrations_table_created():
|
||||
"""init_db creates the registrations table with correct schema."""
|
||||
from tests.conftest import temp_db
|
||||
from backend import db as _db, config as _cfg
|
||||
import aiosqlite
|
||||
|
||||
with temp_db():
|
||||
await _db.init_db()
|
||||
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||
async with conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='registrations'"
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
assert row is not None, "Table 'registrations' not found after init_db()"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 14. DB — password_hash uses PBKDF2 '{salt_hex}:{dk_hex}' format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_password_hash_stored_in_pbkdf2_format():
|
||||
"""Stored password_hash uses '<salt_hex>:<dk_hex>' PBKDF2 format."""
|
||||
from backend import config as _cfg
|
||||
import aiosqlite
|
||||
|
||||
async with make_app_client() as client:
|
||||
with patch("backend.telegram.send_registration_notification", new_callable=AsyncMock):
|
||||
await client.post(
|
||||
"/api/auth/register",
|
||||
json={**_VALID_PAYLOAD, "email": "pbkdf2@example.com", "login": "pbkdf2user"},
|
||||
)
|
||||
|
||||
async with aiosqlite.connect(_cfg.DB_PATH) as conn:
|
||||
conn.row_factory = aiosqlite.Row
|
||||
async with conn.execute(
|
||||
"SELECT password_hash FROM registrations WHERE login = 'pbkdf2user'"
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
|
||||
assert row is not None, "Registration not found in DB"
|
||||
password_hash = row["password_hash"]
|
||||
assert ":" in password_hash, f"Expected 'salt:hash' format, got {password_hash!r}"
|
||||
parts = password_hash.split(":")
|
||||
assert len(parts) == 2, f"Expected exactly one colon separator, got {password_hash!r}"
|
||||
salt_hex, dk_hex = parts
|
||||
# salt = os.urandom(16) → 32 hex chars; dk = SHA-256 output (32 bytes) → 64 hex chars
|
||||
assert len(salt_hex) == 32, f"Expected 32-char salt hex, got {len(salt_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(dk_hex, 16)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue