kin: BATON-FIX-001 Установить FRONTEND_ORIGIN=https://baton.itafrika.com в .env на проде
This commit is contained in:
parent
5c9176fcd9
commit
fd4f32c1c3
1 changed files with 329 additions and 0 deletions
329
tests/test_sec_006.py
Normal file
329
tests/test_sec_006.py
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
"""
|
||||||
|
Tests for BATON-SEC-006: Персистентное хранение rate-limit счётчиков.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
1. Счётчики сохраняются между пересозданием экземпляра приложения (симуляция рестарта).
|
||||||
|
2. TTL-очистка корректно сбрасывает устаревшие записи после истечения окна.
|
||||||
|
3. Превышение лимита возвращает HTTP 429.
|
||||||
|
4. X-Real-IP и X-Forwarded-For корректно парсятся для подсчёта.
|
||||||
|
|
||||||
|
UUID note: All UUIDs below satisfy the v4 pattern validated since BATON-SEC-005.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("BOT_TOKEN", "test-bot-token")
|
||||||
|
os.environ.setdefault("CHAT_ID", "-1001234567890")
|
||||||
|
os.environ.setdefault("WEBHOOK_SECRET", "test-webhook-secret")
|
||||||
|
os.environ.setdefault("WEBHOOK_URL", "https://example.com/api/webhook/telegram")
|
||||||
|
os.environ.setdefault("FRONTEND_ORIGIN", "http://localhost:3000")
|
||||||
|
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend import config, db
|
||||||
|
from tests.conftest import make_app_client
|
||||||
|
|
||||||
|
# ── Valid UUID v4 constants ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_UUID_XREALIP_A = "c0000001-0000-4000-8000-000000000001" # X-Real-IP exhaustion
|
||||||
|
_UUID_XREALIP_B = "c0000002-0000-4000-8000-000000000002" # IP-B (independent counter)
|
||||||
|
_UUID_XFWD = "c0000003-0000-4000-8000-000000000003" # X-Forwarded-For test
|
||||||
|
_UUID_REG_RL = "c0000004-0000-4000-8000-000000000004" # register 429 test
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _tmpdb() -> str:
|
||||||
|
"""Set config.DB_PATH to a fresh temp file and return the path."""
|
||||||
|
path = tempfile.mktemp(suffix=".db")
|
||||||
|
config.DB_PATH = path
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup(path: str) -> None:
|
||||||
|
for ext in ("", "-wal", "-shm"):
|
||||||
|
try:
|
||||||
|
os.unlink(path + ext)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Criterion 1: Persistence across restart ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limits_table_created_by_init_db():
|
||||||
|
"""init_db() creates the rate_limits table in SQLite."""
|
||||||
|
path = _tmpdb()
|
||||||
|
try:
|
||||||
|
await db.init_db()
|
||||||
|
async with aiosqlite.connect(path) as conn:
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='rate_limits'"
|
||||||
|
) as cur:
|
||||||
|
row = await cur.fetchone()
|
||||||
|
assert row is not None, "rate_limits table not found after init_db()"
|
||||||
|
finally:
|
||||||
|
_cleanup(path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_counter_persists_after_db_reinit():
|
||||||
|
"""Counter survives re-initialization of the DB (simulates app restart).
|
||||||
|
|
||||||
|
Before: in-memory app.state.rate_counters was lost on restart.
|
||||||
|
After: SQLite-backed rate_limits table persists across init_db() calls.
|
||||||
|
"""
|
||||||
|
path = _tmpdb()
|
||||||
|
try:
|
||||||
|
await db.init_db()
|
||||||
|
|
||||||
|
c1 = await db.rate_limit_increment("persist:test", 600)
|
||||||
|
c2 = await db.rate_limit_increment("persist:test", 600)
|
||||||
|
c3 = await db.rate_limit_increment("persist:test", 600)
|
||||||
|
assert c3 == 3, f"Expected 3 after 3 increments, got {c3}"
|
||||||
|
|
||||||
|
# Simulate restart: re-initialize DB against the same file
|
||||||
|
await db.init_db()
|
||||||
|
|
||||||
|
# Counter must continue from 3, not reset to 0
|
||||||
|
c4 = await db.rate_limit_increment("persist:test", 600)
|
||||||
|
assert c4 == 4, (
|
||||||
|
f"Expected 4 after reinit + 1 more increment (counter must persist), got {c4}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_cleanup(path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_increment_returns_sequential_counts():
|
||||||
|
"""rate_limit_increment returns 1, 2, 3 on successive calls within window."""
|
||||||
|
path = _tmpdb()
|
||||||
|
try:
|
||||||
|
await db.init_db()
|
||||||
|
c1 = await db.rate_limit_increment("seq:test", 600)
|
||||||
|
c2 = await db.rate_limit_increment("seq:test", 600)
|
||||||
|
c3 = await db.rate_limit_increment("seq:test", 600)
|
||||||
|
assert (c1, c2, c3) == (1, 2, 3), f"Expected (1,2,3), got ({c1},{c2},{c3})"
|
||||||
|
finally:
|
||||||
|
_cleanup(path)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Criterion 2: TTL cleanup resets stale entries ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_ttl_resets_counter_after_window_expires():
|
||||||
|
"""Counter resets to 1 when the time window has expired (TTL cleanup).
|
||||||
|
|
||||||
|
time.time() is mocked — no real sleep required.
|
||||||
|
"""
|
||||||
|
path = _tmpdb()
|
||||||
|
try:
|
||||||
|
await db.init_db()
|
||||||
|
|
||||||
|
with mock.patch("backend.db.time") as mock_time:
|
||||||
|
mock_time.time.return_value = 1000.0 # window_start = t0
|
||||||
|
|
||||||
|
c1 = await db.rate_limit_increment("ttl:test", 10)
|
||||||
|
c2 = await db.rate_limit_increment("ttl:test", 10)
|
||||||
|
c3 = await db.rate_limit_increment("ttl:test", 10)
|
||||||
|
assert c3 == 3
|
||||||
|
|
||||||
|
# Jump 11 seconds ahead (window = 10s → expired)
|
||||||
|
mock_time.time.return_value = 1011.0
|
||||||
|
|
||||||
|
c4 = await db.rate_limit_increment("ttl:test", 10)
|
||||||
|
|
||||||
|
assert c4 == 1, (
|
||||||
|
f"Expected counter reset to 1 after window expired, got {c4}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_cleanup(path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_ttl_does_not_reset_within_window():
|
||||||
|
"""Counter is NOT reset when the window has NOT expired yet."""
|
||||||
|
path = _tmpdb()
|
||||||
|
try:
|
||||||
|
await db.init_db()
|
||||||
|
|
||||||
|
with mock.patch("backend.db.time") as mock_time:
|
||||||
|
mock_time.time.return_value = 1000.0
|
||||||
|
|
||||||
|
await db.rate_limit_increment("ttl:within", 10)
|
||||||
|
await db.rate_limit_increment("ttl:within", 10)
|
||||||
|
c3 = await db.rate_limit_increment("ttl:within", 10)
|
||||||
|
assert c3 == 3
|
||||||
|
|
||||||
|
# Only 5 seconds passed (window = 10s, still active)
|
||||||
|
mock_time.time.return_value = 1005.0
|
||||||
|
|
||||||
|
c4 = await db.rate_limit_increment("ttl:within", 10)
|
||||||
|
|
||||||
|
assert c4 == 4, (
|
||||||
|
f"Expected 4 (counter continues inside window), got {c4}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_cleanup(path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_ttl_boundary_exactly_at_window_end():
|
||||||
|
"""Counter resets when elapsed time equals exactly the window duration."""
|
||||||
|
path = _tmpdb()
|
||||||
|
try:
|
||||||
|
await db.init_db()
|
||||||
|
|
||||||
|
with mock.patch("backend.db.time") as mock_time:
|
||||||
|
mock_time.time.return_value = 1000.0
|
||||||
|
|
||||||
|
await db.rate_limit_increment("ttl:boundary", 10)
|
||||||
|
await db.rate_limit_increment("ttl:boundary", 10)
|
||||||
|
|
||||||
|
# Exactly at window boundary (elapsed == window → stale)
|
||||||
|
mock_time.time.return_value = 1010.0
|
||||||
|
|
||||||
|
c = await db.rate_limit_increment("ttl:boundary", 10)
|
||||||
|
|
||||||
|
assert c == 1, (
|
||||||
|
f"Expected reset at exact window boundary (elapsed == window), got {c}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_cleanup(path)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Criterion 3: HTTP 429 when rate limit exceeded ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_returns_429_after_rate_limit_exceeded():
|
||||||
|
"""POST /api/register returns 429 on the 6th request from the same IP.
|
||||||
|
|
||||||
|
Register limit = 5 requests per 600s window.
|
||||||
|
"""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
ip_hdrs = {"X-Real-IP": "192.0.2.10"}
|
||||||
|
statuses = []
|
||||||
|
for _ in range(6):
|
||||||
|
r = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_REG_RL, "name": "RateLimitTest"},
|
||||||
|
headers=ip_hdrs,
|
||||||
|
)
|
||||||
|
statuses.append(r.status_code)
|
||||||
|
|
||||||
|
assert statuses[-1] == 429, (
|
||||||
|
f"Expected 429 on 6th register request, got statuses: {statuses}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_first_5_requests_are_allowed():
|
||||||
|
"""First 5 POST /api/register requests from the same IP must all return 200."""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
ip_hdrs = {"X-Real-IP": "192.0.2.11"}
|
||||||
|
statuses = []
|
||||||
|
for _ in range(5):
|
||||||
|
r = await client.post(
|
||||||
|
"/api/register",
|
||||||
|
json={"uuid": _UUID_REG_RL, "name": "RateLimitTest"},
|
||||||
|
headers=ip_hdrs,
|
||||||
|
)
|
||||||
|
statuses.append(r.status_code)
|
||||||
|
|
||||||
|
assert all(s == 200 for s in statuses), (
|
||||||
|
f"Expected all 5 register requests to return 200, got: {statuses}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Criterion 4: X-Real-IP and X-Forwarded-For for rate counting ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_x_real_ip_header_is_used_for_rate_counting():
|
||||||
|
"""Rate counter keys are derived from X-Real-IP: two requests sharing
|
||||||
|
the same X-Real-IP share the same counter and collectively hit the 429 limit.
|
||||||
|
"""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
await client.post(
|
||||||
|
"/api/register", json={"uuid": _UUID_XREALIP_A, "name": "RealIPUser"}
|
||||||
|
)
|
||||||
|
|
||||||
|
ip_hdrs = {"X-Real-IP": "203.0.113.10"}
|
||||||
|
payload = {"user_id": _UUID_XREALIP_A, "timestamp": 1742478000000}
|
||||||
|
|
||||||
|
statuses = []
|
||||||
|
for _ in range(11):
|
||||||
|
r = await client.post("/api/signal", json=payload, headers=ip_hdrs)
|
||||||
|
statuses.append(r.status_code)
|
||||||
|
|
||||||
|
assert statuses[-1] == 429, (
|
||||||
|
f"Expected 429 on 11th signal with same X-Real-IP, got: {statuses}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_x_forwarded_for_header_is_used_for_rate_counting():
|
||||||
|
"""Rate counter keys are derived from X-Forwarded-For (first IP) when
|
||||||
|
X-Real-IP is absent: requests sharing the same forwarded IP hit the limit.
|
||||||
|
"""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
await client.post(
|
||||||
|
"/api/register", json={"uuid": _UUID_XFWD, "name": "FwdUser"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Chain: first IP is the original client (only that one is used)
|
||||||
|
fwd_hdrs = {"X-Forwarded-For": "198.51.100.5, 10.0.0.1, 172.16.0.1"}
|
||||||
|
payload = {"user_id": _UUID_XFWD, "timestamp": 1742478000000}
|
||||||
|
|
||||||
|
statuses = []
|
||||||
|
for _ in range(11):
|
||||||
|
r = await client.post("/api/signal", json=payload, headers=fwd_hdrs)
|
||||||
|
statuses.append(r.status_code)
|
||||||
|
|
||||||
|
assert statuses[-1] == 429, (
|
||||||
|
f"Expected 429 on 11th request with same X-Forwarded-For first IP, got: {statuses}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_different_x_real_ip_values_have_independent_counters():
|
||||||
|
"""Exhausting the rate limit for IP-A must not block IP-B.
|
||||||
|
|
||||||
|
Verifies that rate-limit keys are truly per-IP.
|
||||||
|
"""
|
||||||
|
async with make_app_client() as client:
|
||||||
|
await client.post(
|
||||||
|
"/api/register", json={"uuid": _UUID_XREALIP_A, "name": "IPA"}
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
"/api/register", json={"uuid": _UUID_XREALIP_B, "name": "IPB"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exhaust limit for IP-A
|
||||||
|
for _ in range(11):
|
||||||
|
await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_XREALIP_A, "timestamp": 1742478000000},
|
||||||
|
headers={"X-Real-IP": "198.51.100.100"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# IP-B has its own independent counter — must not be blocked
|
||||||
|
r = await client.post(
|
||||||
|
"/api/signal",
|
||||||
|
json={"user_id": _UUID_XREALIP_B, "timestamp": 1742478000000},
|
||||||
|
headers={"X-Real-IP": "198.51.100.200"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200, (
|
||||||
|
f"IP-B was incorrectly blocked after IP-A exhausted its counter: {r.status_code}"
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue