Files
xsl 9c1802130d chore(prep): shared groundwork for batch-3 (admin/golden/cache)
第三批(T12 后台/T13 黄金集/T14 检索缓存)协调提交:
- kb:KbAssetService(登记/审核,tryon_ready 只能随 approved 打开)+ Asset 模型与 repo
- retrieval:list_gaps 缺口看板查询 + invalidate_cache() 钩子(T14 背后实现真缓存)
- 配置:ADMIN_TOKEN(空=后台 404)+ 检索缓存三参数
- pytest golden marker(默认跳过,CI 强制跑)+ Makefile golden + CI 防线 5c
- 测试隔离修复:test_auth_sms 加 autouse 清 sms:* 键夹具(频控计数跨运行
  累积会毒化后续运行——随机 IP 空间仅 254 且部分用例固定 device_id)
2026-08-14 20:57:52 +08:00

228 lines
8.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""短信登录集成(T11,FR-ACC-04/14)—— 真实 PostgreSQL + Redis,SMS_PROVIDER=fake。
测试钩子:sender 经 ``app.state.sms_auth`` 注入为捕获型 fake(路由的惰性装配
优先读 app.state),验证码从 fixture 拿而不是去挖生产日志——生产代码不为
测试留任何后门。每用例用随机号码/IP/设备:频控计数按 UTC+8 自然日分桶,
固定值会跨用例、跨次运行、跨天污染 Redis 计数。
``sms_resend_cooldown_seconds`` 覆写为 0:同号二次登录(重复发送)与刷量
循环不必等 60s 真实冷却;冷却语义在单测里用注入时钟验证。
"""
from __future__ import annotations
import os
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import httpx
import pytest
from packages.identity.sms_login import RedisSmsStore, SmsAuthService
def _infra_available() -> bool:
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _infra_available(),
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
),
]
class CapturingSender:
"""捕获型 fake sender:把发出去的码留在内存里供 verify 使用。"""
def __init__(self) -> None:
self.codes: dict[str, str] = {}
async def send_code(self, phone: str, masked: str, code: str) -> None:
self.codes[phone] = code
def _rand_phone() -> str:
# "139" + 8 位随机数字:合法大陆号形;随机空间 1e8,跨运行撞桶概率可忽略
return f"139{uuid.uuid4().int % 10**8:08d}"
def _rand_ip() -> str:
# 随机源 IP(文档保留网段 198.51.100.0/24):让 IP 维度计数互不污染
return f"198.51.100.{uuid.uuid4().int % 254 + 1}"
@pytest.fixture(autouse=True)
async def _clean_sms_counters() -> AsyncIterator[None]:
"""每用例前清空 sms:* 计数键。
频控计数落在常驻 Redis 里,会跨 pytest 运行累积(随机 IP 只从
198.51.100.0/24 取值、部分用例固定 device_id),历史运行的计数会把
本运行的合法发送挤过阈值。测试隔离靠自己清键,不依赖随机性。
"""
if not _infra_available():
yield
return
from packages.shared.redis_client import get_redis
redis = await get_redis()
keys = [k async for k in redis.scan_iter(match="sms:*")]
if keys:
await redis.delete(*keys)
yield
@pytest.fixture
async def env() -> AsyncIterator[tuple[Any, CapturingSender]]:
from apps.api.main import create_app
from packages.shared.config import get_settings
from packages.shared.redis_client import get_redis
settings = get_settings().model_copy(update={"sms_resend_cooldown_seconds": 0})
app = create_app(settings)
sender = CapturingSender()
app.state.sms_auth = SmsAuthService(RedisSmsStore(get_redis()), sender, settings=settings)
async with app.router.lifespan_context(app):
yield app, sender
@asynccontextmanager
async def _client(app: Any, ip: str | None = None) -> AsyncIterator[httpx.AsyncClient]:
"""ASGI 客户端;``client=(ip, port)`` 决定 Request.client.host(IP 维度输入)。"""
transport = httpx.ASGITransport(app=app, client=(ip or _rand_ip(), 54321))
async with httpx.AsyncClient(transport=transport, base_url="http://test") as http:
yield http
async def _send(
http: httpx.AsyncClient, phone: str, device_id: str | None = None
) -> httpx.Response:
return await http.post("/api/v1/auth/sms/send", json={"phone": phone, "device_id": device_id})
async def test_send_then_verify_returns_working_token_pair(
env: tuple[Any, CapturingSender],
) -> None:
app, sender = env
phone = _rand_phone()
async with _client(app) as http:
sent = await _send(http, phone, device_id="dev-a")
assert sent.status_code == 200
body = sent.json()
assert body["code"] == 0
assert body["data"] == {"sent": True}
verify = await http.post(
"/api/v1/auth/sms/verify",
json={"phone": phone, "code": sender.codes[phone], "device_id": "dev-a"},
)
assert verify.status_code == 200
data = verify.json()["data"]
assert data["access_token"]
assert data["refresh_token"]
assert data["expires_in"] > 0
assert data["refresh_expires_in"] > 0
assert data["user_id"]
# 新签发的 access 立即可用(双令牌链路闭环)
quota = await http.get(
"/api/v1/quota", headers={"Authorization": f"Bearer {data['access_token']}"}
)
assert quota.status_code == 200
assert quota.json()["code"] == 0
async def test_same_phone_relogin_maps_to_same_user(env: tuple[Any, CapturingSender]) -> None:
app, sender = env
phone = _rand_phone()
async with _client(app) as http:
user_ids = []
for _ in range(2):
await _send(http, phone) # cooldown=0,可立即重发取新码
verify = await http.post(
"/api/v1/auth/sms/verify", json={"phone": phone, "code": sender.codes[phone]}
)
assert verify.status_code == 200
user_ids.append(verify.json()["data"]["user_id"])
# provider="phone" 映射唯一:同号两次登录拿到同一 user_id
assert user_ids[0] == user_ids[1]
async def test_scripted_flood_eleventh_send_hits_daily_limit(
env: tuple[Any, CapturingSender],
) -> None:
"""FR-ACC-14 验收原文:脚本化刷量证明拦截有效——同号连发 11 次。
前 5 次放行;第 6~10 次先撞 1h≤5 的小时窗(42910);第 11 次撞 24h≤10
的日窗(42911)。设备逐次更换以隔离设备维度。
"""
app, _ = env
phone = _rand_phone()
async with _client(app) as http:
for i in range(11):
resp = await _send(http, phone, device_id=f"flood-dev-{i}")
body = resp.json()
if i < 5:
assert resp.status_code == 200, body
elif i < 10:
assert resp.status_code == 429 and body["code"] == 42910, body
else:
assert resp.status_code == 429 and body["code"] == 42911, body
async def test_ip_dimension_blocks_twenty_first_send(env: tuple[Any, CapturingSender]) -> None:
"""IP 维度刷量:同一 IP 打 21 个不同号码,第 21 次 42911。"""
app, _ = env
ip = _rand_ip()
async with _client(app, ip=ip) as http:
for _ in range(20):
resp = await _send(http, _rand_phone(), device_id=f"ipdev-{uuid.uuid4().hex[:8]}")
assert resp.status_code == 200, resp.text
blocked = await _send(http, _rand_phone(), device_id=f"ipdev-{uuid.uuid4().hex[:8]}")
assert blocked.status_code == 429
assert blocked.json()["code"] == 42911
async def test_device_dimension_blocks_eleventh_send(env: tuple[Any, CapturingSender]) -> None:
"""设备维度刷量:同一设备换号换 IP 打 11 次,第 11 次 42911。"""
app, _ = env
device = f"dev-{uuid.uuid4().hex[:12]}"
async with _client(app) as http: # 本用例客户端 IP 固定但仅 11 次发送 < IP 上限 20
for _ in range(10):
resp = await _send(http, _rand_phone(), device_id=device)
assert resp.status_code == 200, resp.text
blocked = await _send(http, _rand_phone(), device_id=device)
assert blocked.status_code == 429
assert blocked.json()["code"] == 42911
async def test_five_wrong_verifies_invalidate_the_code(env: tuple[Any, CapturingSender]) -> None:
"""FR-ACC-04:验证码错 5 次作废——第 5 次仍报 40110,之后正确码也只剩 40111。"""
app, sender = env
phone = _rand_phone()
async with _client(app) as http:
await _send(http, phone)
code = sender.codes[phone]
wrong = code[:-1] + ("0" if code[-1] != "0" else "1") # 保证 != 真码
for _ in range(5):
resp = await http.post("/api/v1/auth/sms/verify", json={"phone": phone, "code": wrong})
assert resp.status_code == 401
assert resp.json()["code"] == 40110
# 码已作废:即使拿到正确码也只能 40111(一次性 + 作废都不留活口)
after = await http.post("/api/v1/auth/sms/verify", json={"phone": phone, "code": code})
assert after.status_code == 401
assert after.json()["code"] == 40111
async def test_send_rejects_malformed_phone(env: tuple[Any, CapturingSender]) -> None:
"""服务层规范化兜底:能过 Pydantic 长度但非大陆号形 → 40000。"""
app, _ = env
async with _client(app) as http:
resp = await _send(http, "00000")
assert resp.status_code == 400
assert resp.json()["code"] == 40000