274 lines
10 KiB
Python
274 lines
10 KiB
Python
"""邮箱登录集成(FR-ACC-14 v2 变更)—— 真实 PostgreSQL + Redis,EMAIL_PROVIDER=fake。
|
||
|
||
测试钩子:sender 经 ``app.state.email_auth`` 注入为捕获型 fake(路由的惰性装配
|
||
优先读 app.state),验证码从 fixture 拿而不是去挖生产日志——生产代码不为
|
||
测试留任何后门。每用例用随机邮箱/IP/设备:频控计数按 UTC+8 自然日分桶,
|
||
固定值会跨用例、跨次运行、跨天污染 Redis 计数。
|
||
|
||
``email_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.email_login import EmailAuthService, RedisEmailStore
|
||
|
||
|
||
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:把发出去的码留在内存里供 login 使用。"""
|
||
|
||
def __init__(self) -> None:
|
||
self.codes: dict[str, str] = {}
|
||
|
||
async def send_code(self, email: str, masked: str, code: str) -> None:
|
||
self.codes[email] = code
|
||
|
||
|
||
def _rand_email() -> str:
|
||
# 随机 local 部分:跨运行撞桶概率可忽略
|
||
return f"it-{uuid.uuid4().hex[:12]}@example.com"
|
||
|
||
|
||
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_email_counters() -> AsyncIterator[None]:
|
||
"""每用例前清空 email:* 计数键(理由同 test_auth_sms 的同名 fixture)。"""
|
||
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="email:*")]
|
||
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={"email_resend_cooldown_seconds": 0})
|
||
app = create_app(settings)
|
||
sender = CapturingSender()
|
||
app.state.email_auth = EmailAuthService(RedisEmailStore(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 _code(
|
||
http: httpx.AsyncClient, email: str, device_key: str | None = None
|
||
) -> httpx.Response:
|
||
headers = {"X-Device-Key": device_key} if device_key else {}
|
||
return await http.post("/api/v1/auth/email/code", json={"email": email}, headers=headers)
|
||
|
||
|
||
async def test_send_then_login_returns_working_token_pair(
|
||
env: tuple[Any, CapturingSender],
|
||
) -> None:
|
||
app, sender = env
|
||
email = _rand_email()
|
||
async with _client(app) as http:
|
||
sent = await _code(http, email, device_key="dev-a")
|
||
assert sent.status_code == 200
|
||
body = sent.json()
|
||
assert body["code"] == 0
|
||
assert body["data"]["sent"] is True
|
||
# cooldown_seconds 回报的是进程配置值(全局 Settings,不受 fixture 覆写影响)
|
||
assert body["data"]["cooldown_seconds"] == 60
|
||
|
||
login = await http.post(
|
||
"/api/v1/auth/email/login",
|
||
json={"email": email, "code": sender.codes[email]},
|
||
)
|
||
assert login.status_code == 200
|
||
data = login.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_email_relogin_maps_to_same_user(env: tuple[Any, CapturingSender]) -> None:
|
||
"""幂等:同邮箱重复登录返回同一 user_id(provider=email 映射唯一)。"""
|
||
app, sender = env
|
||
email = _rand_email()
|
||
async with _client(app) as http:
|
||
user_ids = []
|
||
for _ in range(2):
|
||
await _code(http, email) # cooldown=0,可立即重发取新码
|
||
login = await http.post(
|
||
"/api/v1/auth/email/login",
|
||
json={"email": email, "code": sender.codes[email]},
|
||
)
|
||
assert login.status_code == 200
|
||
user_ids.append(login.json()["data"]["user_id"])
|
||
assert user_ids[0] == user_ids[1]
|
||
|
||
|
||
async def test_email_case_insensitive_login(env: tuple[Any, CapturingSender]) -> None:
|
||
"""大小写归一:注册用小写,登录用大写混搭,映射到同一 user_id。"""
|
||
app, sender = env
|
||
email = _rand_email()
|
||
async with _client(app) as http:
|
||
await _code(http, email)
|
||
login = await http.post(
|
||
"/api/v1/auth/email/login",
|
||
json={"email": email.upper(), "code": sender.codes[email]},
|
||
)
|
||
assert login.status_code == 200
|
||
first = login.json()["data"]["user_id"]
|
||
|
||
await _code(http, email)
|
||
relogin = await http.post(
|
||
"/api/v1/auth/email/login",
|
||
json={"email": email, "code": sender.codes[email]},
|
||
)
|
||
assert relogin.status_code == 200
|
||
assert relogin.json()["data"]["user_id"] == first
|
||
|
||
|
||
async def test_scripted_flood_eleventh_send_hits_daily_limit(
|
||
env: tuple[Any, CapturingSender],
|
||
) -> None:
|
||
"""FR-ACC-14 口径的邮箱版验收:同邮箱连发 11 次。
|
||
|
||
前 5 次放行;第 6~10 次先撞 1h≤5 的小时窗(42912);第 11 次撞 24h≤10
|
||
的日窗(42913)。设备逐次更换以隔离设备维度。
|
||
"""
|
||
app, _ = env
|
||
email = _rand_email()
|
||
async with _client(app) as http:
|
||
for i in range(11):
|
||
resp = await _code(http, email, device_key=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"] == 42912, body
|
||
else:
|
||
assert resp.status_code == 429 and body["code"] == 42913, body
|
||
|
||
|
||
async def test_ip_dimension_blocks_twenty_first_send(env: tuple[Any, CapturingSender]) -> None:
|
||
"""IP 维度刷量:同一 IP 打 21 个不同邮箱,第 21 次 42913。"""
|
||
app, _ = env
|
||
ip = _rand_ip()
|
||
async with _client(app, ip=ip) as http:
|
||
for _ in range(20):
|
||
resp = await _code(http, _rand_email(), device_key=f"ipdev-{uuid.uuid4().hex[:8]}")
|
||
assert resp.status_code == 200, resp.text
|
||
blocked = await _code(http, _rand_email(), device_key=f"ipdev-{uuid.uuid4().hex[:8]}")
|
||
assert blocked.status_code == 429
|
||
assert blocked.json()["code"] == 42913
|
||
|
||
|
||
async def test_device_dimension_blocks_eleventh_send(env: tuple[Any, CapturingSender]) -> None:
|
||
"""设备维度刷量:同一 X-Device-Key 换邮箱换 IP 打 11 次,第 11 次 42913。"""
|
||
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 _code(http, _rand_email(), device_key=device)
|
||
assert resp.status_code == 200, resp.text
|
||
blocked = await _code(http, _rand_email(), device_key=device)
|
||
assert blocked.status_code == 429
|
||
assert blocked.json()["code"] == 42913
|
||
|
||
|
||
async def test_device_dimension_falls_back_to_ip(env: tuple[Any, CapturingSender]) -> None:
|
||
"""缺 X-Device-Key 时设备维度以 IP 兜底:同 IP 打 11 个邮箱,第 11 次 42913。"""
|
||
app, _ = env
|
||
ip = _rand_ip()
|
||
async with _client(app, ip=ip) as http:
|
||
for _ in range(10):
|
||
resp = await _code(http, _rand_email())
|
||
assert resp.status_code == 200, resp.text
|
||
blocked = await _code(http, _rand_email())
|
||
assert blocked.status_code == 429
|
||
assert blocked.json()["code"] == 42913
|
||
|
||
|
||
async def test_five_wrong_logins_invalidate_the_code(env: tuple[Any, CapturingSender]) -> None:
|
||
"""验证码错 5 次作废——第 5 次仍报 40112,之后正确码也只剩 40113。"""
|
||
app, sender = env
|
||
email = _rand_email()
|
||
async with _client(app) as http:
|
||
await _code(http, email)
|
||
code = sender.codes[email]
|
||
wrong = code[:-1] + ("0" if code[-1] != "0" else "1") # 保证 != 真码
|
||
for _ in range(5):
|
||
resp = await http.post("/api/v1/auth/email/login", json={"email": email, "code": wrong})
|
||
assert resp.status_code == 401
|
||
assert resp.json()["code"] == 40112
|
||
# 码已作废:即使拿到正确码也只能 40113(一次性 + 作废都不留活口)
|
||
after = await http.post("/api/v1/auth/email/login", json={"email": email, "code": code})
|
||
assert after.status_code == 401
|
||
assert after.json()["code"] == 40113
|
||
|
||
|
||
async def test_login_without_code_is_expired(env: tuple[Any, CapturingSender]) -> None:
|
||
"""从未发过验证码的邮箱直接登录:40113(端点活着、校验链路通)。"""
|
||
app, _ = env
|
||
async with _client(app) as http:
|
||
resp = await http.post(
|
||
"/api/v1/auth/email/login",
|
||
json={"email": _rand_email(), "code": "000000"},
|
||
)
|
||
assert resp.status_code == 401
|
||
assert resp.json()["code"] == 40113
|
||
|
||
|
||
async def test_code_rejects_malformed_email(env: tuple[Any, CapturingSender]) -> None:
|
||
"""服务层规范化兜底:能过 Pydantic 长度但非邮箱 → 40000。"""
|
||
app, _ = env
|
||
async with _client(app) as http:
|
||
resp = await _code(http, "not-an-email")
|
||
assert resp.status_code == 400
|
||
assert resp.json()["code"] == 40000
|