"""限流:认证按 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