"""短信登录集成(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 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