Files
item_bank/tests/test_ratelimit.py
T
2026-08-01 16:50:55 +08:00

139 lines
4.6 KiB
Python

"""限流:认证按 IP、AI 按用户。
限流默认在测试里关闭(conftest 设了 RATE_LIMIT_ENABLED=false),
这里手动开启 limiter 并重置计数器。
"""
import pytest
from app.ratelimit import limiter
@pytest.fixture
def rate_limit_on():
"""临时开启限流,结束后恢复并清空计数。"""
limiter.enabled = True
limiter.reset()
yield
limiter.reset()
limiter.enabled = False
async def test_login_rate_limited_by_ip(client, rate_limit_on):
"""暴力破解应在若干次失败后被挡住。"""
statuses = []
for _ in range(15):
r = await client.post(
"/api/auth/login", data={"username": "nobody", "password": "wrong"}
)
statuses.append(r.status_code)
assert 401 in statuses, "前几次应正常返回 401"
assert 429 in statuses, "超过阈值后应返回 429"
# 一旦触发,后续应持续被限
assert statuses[-1] == 429
async def test_ai_limit_is_per_user_not_per_ip(client, auth_headers, rate_limit_on):
"""同一 IP 下不同用户的 AI 额度互相独立——否则一人触发会误伤所有人。"""
ha = await auth_headers("alice")
hb = await auth_headers("bob")
q = {
"type": "single_choice",
"stem_markdown": "q",
"options": [{"key": "A", "text_markdown": "1"}],
"correct_answer": ["A"],
}
qa = (await client.post("/api/questions", json=q, headers=ha)).json()["id"]
qb = (await client.post("/api/questions", json=q, headers=hb)).json()["id"]
async def attempt(qid, headers):
r = await client.post(
f"/api/questions/{qid}/attempts", json={"user_answer": ["A"]}, headers=headers
)
return r.json()["id"]
# alice 一直判分到被限
alice_hit_limit = False
for _ in range(30):
aid = await attempt(qa, ha)
r = await client.post(f"/api/attempts/{aid}/judge", headers=ha)
if r.status_code == 429:
alice_hit_limit = True
break
assert alice_hit_limit, "alice 应触发 AI 限流"
# bob 不该受影响
bid = await attempt(qb, hb)
r = await client.post(f"/api/attempts/{bid}/judge", headers=hb)
assert r.status_code == 200, "bob 与 alice 共用 IP,但不该被牵连"
async def test_recording_attempt_is_not_ai_rate_limited(
client, auth_headers, rate_limit_on
):
"""记录作答不消耗 AI 额度,就不该受 AI 限流约束。
否则"先记下来、以后再判"这个主路径会被判分的额度拖住。
"""
h = await auth_headers()
qid = (
await client.post(
"/api/questions",
json={"type": "unclassified", "stem_markdown": "随手记的题"},
headers=h,
)
).json()["id"]
# 远超 AI 限额的次数,全部应成功
for i in range(40):
r = await client.post(
f"/api/questions/{qid}/attempts",
json={"user_answer": [f"{i}次尝试"]},
headers=h,
)
assert r.status_code == 201, f"{i + 1} 次记录被限流了"
async def test_limit_counts_across_different_ids_in_path(
client, auth_headers, configure_ai, rate_limit_on
):
"""回归:限流必须按 endpoint 计数,不能按完整 URL。
/api/questions/{id}/ai/explain 这类路径带 ID,若按 URL 计数则每个 id
各占一个桶 —— AI 端点就等于没有限流,别人可以刷爆你的 API 额度。
"""
from unittest.mock import patch
from app.config import settings
from app.services.llm.client import LLMResult
h = await auth_headers()
await configure_ai(h)
async def fake_text(self, system, user, json_mode=False, model=None):
return LLMResult("讲解", "text-model", 1, 1)
# 每次都换一条不同的笔记(也就是不同的 URL)
limit_num = int(settings.rate_limit_ai.split("/")[0])
statuses = []
with patch("app.services.llm.client.LLMClient.chat_text", fake_text):
for i in range(limit_num + 3):
qid = (
await client.post(
"/api/questions",
json={"type": "unclassified", "stem_markdown": f"{i}"},
headers=h,
)
).json()["id"]
r = await client.post(f"/api/questions/{qid}/ai/explain", headers=h)
statuses.append(r.status_code)
assert 429 in statuses, "不同 URL 也必须共享同一个 AI 限流额度"
async def test_health_not_rate_limited(client, rate_limit_on):
"""健康检查会被探针频繁调用,不能限流。"""
for _ in range(30):
assert (await client.get("/api/health")).status_code == 200