feat(auth): 邮箱验证码登录通道(正式兜底,替代不可用的短信)——/auth/email/code + /auth/email/login,SmtpEmailSender(stdlib smtplib,无新依赖)+ FakeEmailSender(仅本地);频控对齐短信口径(60s 冷却/邮箱 1h≤5·24h≤10/IP≤20/设备≤10);新增 EMAIL_*/SMTP_* 配置与 40112/40113/42912/42913/61001 错误码
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
"""邮箱登录集成(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
|
||||
@@ -0,0 +1,391 @@
|
||||
"""EmailAuthService 单测(packages/identity/email_login.py)——无 IO、无网络。
|
||||
|
||||
存储用自写内存 dict 实现 Protocol 化的 EmailStore 接口(与 test_sms_login
|
||||
同一做法,不引 fakeredis);TTL 用可注入时钟模拟过期。频控计数语义按
|
||||
「请求」计(见被测模块 docstring),错误码矩阵对应 FR-ACC-14 v2 的
|
||||
三重频控(邮箱版)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.identity.email_login import (
|
||||
EmailAuthService,
|
||||
FakeEmailSender,
|
||||
SmtpEmailSender,
|
||||
build_email_auth_service,
|
||||
email_hash_key,
|
||||
email_union_id,
|
||||
masked_email,
|
||||
normalize_email,
|
||||
)
|
||||
from packages.identity.sms_login import hash_code
|
||||
from packages.shared.config import Settings
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
|
||||
EMAIL = "someone@example.com"
|
||||
MASKED = "so***@example.com"
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""单调时钟桩:advance 推进 TTL 判定,单测无需真实等待。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._now = 1000.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self._now
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self._now += seconds
|
||||
|
||||
|
||||
class InMemoryEmailStore:
|
||||
"""dict 实现的 EmailStore:键 → (值, 过期时刻);过期惰性清除。"""
|
||||
|
||||
def __init__(self, clock: Callable[[], float] | None = None) -> None:
|
||||
self._data: dict[str, tuple[str, float]] = {}
|
||||
self._clock = clock or time.monotonic
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
entry = self._data.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
value, expires_at = entry
|
||||
if self._clock() >= expires_at:
|
||||
self._data.pop(key, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
async def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
||||
self._data[key] = (value, self._clock() + ttl_seconds)
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
self._data.pop(key, None)
|
||||
|
||||
async def incr(self, key: str, ttl_seconds: int) -> int:
|
||||
current = await self.get(key) # 复用过期语义,与 Redis 行为对齐
|
||||
nxt = int(current) + 1 if current is not None else 1
|
||||
self._data[key] = (str(nxt), self._clock() + ttl_seconds)
|
||||
return nxt
|
||||
|
||||
|
||||
class CapturingSender:
|
||||
"""捕获型 fake:记下发出的 (邮箱, 掩码, 验证码),不打日志。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[tuple[str, str, str]] = []
|
||||
|
||||
async def send_code(self, email: str, masked: str, code: str) -> None:
|
||||
self.sent.append((email, masked, code))
|
||||
|
||||
|
||||
class FailingSender:
|
||||
async def send_code(self, email: str, masked: str, code: str) -> None:
|
||||
raise RuntimeError("provider down")
|
||||
|
||||
|
||||
def _settings(**overrides: Any) -> Settings:
|
||||
"""单测配置:不吃 worktree 的 .env(_env_file=None),保持确定性。
|
||||
|
||||
默认 cooldown=0:让频控矩阵只受被测的那一个维度约束(冷却与限额分开测)。
|
||||
"""
|
||||
values: dict[str, Any] = {"_env_file": None, "email_resend_cooldown_seconds": 0}
|
||||
values.update(overrides)
|
||||
return Settings(**values)
|
||||
|
||||
|
||||
def _service(
|
||||
settings: Settings, sender: Any | None = None
|
||||
) -> tuple[EmailAuthService, InMemoryEmailStore, CapturingSender, FakeClock]:
|
||||
clock = FakeClock()
|
||||
store = InMemoryEmailStore(clock)
|
||||
capturing = CapturingSender()
|
||||
service = EmailAuthService(store, sender or capturing, settings=settings)
|
||||
return service, store, capturing, clock
|
||||
|
||||
|
||||
def _wrong_code(code: str) -> str:
|
||||
"""保证与真码不同的 6 位错码(尾位翻转)。"""
|
||||
return code[:-1] + ("0" if code[-1] != "0" else "1")
|
||||
|
||||
|
||||
def _last_code(sender: CapturingSender) -> str:
|
||||
return sender.sent[-1][2]
|
||||
|
||||
|
||||
# --- 邮箱规范化与掩码 ---------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("someone@example.com", "someone@example.com"),
|
||||
(" Someone@Example.COM ", "someone@example.com"),
|
||||
("a.b+c@example.com", "a.b+c@example.com"),
|
||||
],
|
||||
)
|
||||
def test_normalize_email_lowercases_and_strips(raw: str, expected: str) -> None:
|
||||
assert normalize_email(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["not-an-email", "a@b", "a b@c.com", "plainaddress", "@x.com"])
|
||||
def test_normalize_email_rejects_invalid_formats(bad: str) -> None:
|
||||
# 非法格式是 400 类错误,且不消耗频控额度(不碰存储)
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
normalize_email(bad)
|
||||
assert excinfo.value.code == ErrorCode.BAD_REQUEST
|
||||
|
||||
|
||||
def test_masked_email_hides_local_part() -> None:
|
||||
assert masked_email(EMAIL) == MASKED
|
||||
assert "someone" not in masked_email(EMAIL)
|
||||
|
||||
|
||||
def test_email_hash_key_is_16_hex_chars() -> None:
|
||||
digest = email_hash_key(EMAIL)
|
||||
assert len(digest) == 16
|
||||
assert re.fullmatch(r"[0-9a-f]{16}", digest)
|
||||
|
||||
|
||||
def test_email_union_id_is_plaintext_lowercase_email() -> None:
|
||||
# 产品契约(与 phone 通道哈希 union_id 不同):email union_id 即明文小写邮箱
|
||||
uid = email_union_id(" Someone@Example.COM ")
|
||||
assert uid == "email:someone@example.com"
|
||||
|
||||
|
||||
# --- 发送:码形态与存储纪律 ---------------------------------------------------
|
||||
|
||||
|
||||
async def test_request_code_sends_six_digit_code() -> None:
|
||||
service, _, sender, _ = _service(_settings())
|
||||
await service.request_code(EMAIL, ip="1.2.3.4", device_id="dev-1")
|
||||
assert len(sender.sent) == 1
|
||||
email, masked, code = sender.sent[0]
|
||||
assert email == EMAIL
|
||||
assert masked == MASKED
|
||||
assert re.fullmatch(r"\d{6}", code)
|
||||
|
||||
|
||||
async def test_code_stored_as_sha256_never_plaintext() -> None:
|
||||
service, store, sender, _ = _service(_settings())
|
||||
await service.request_code(EMAIL, ip="203.0.113.7", device_id="dev-1")
|
||||
code = _last_code(sender)
|
||||
entry = store._data.get(f"email:code:{email_hash_key(EMAIL)}")
|
||||
assert entry is not None and entry[0] == hash_code(code)
|
||||
for key, (value, _) in store._data.items():
|
||||
assert EMAIL not in key and EMAIL not in value
|
||||
assert code not in key and code not in value
|
||||
assert "203.0.113.7" not in key and "203.0.113.7" not in value
|
||||
assert "dev-1" not in key
|
||||
|
||||
|
||||
# --- 频控矩阵(FR-ACC-14 v2)---------------------------------------------------
|
||||
|
||||
|
||||
async def test_hourly_limit_sixth_request_rejected() -> None:
|
||||
# 同邮箱 1h ≤ 5:第 6 次 42912
|
||||
service, _, sender, _ = _service(_settings(email_hourly_limit=5))
|
||||
for _ in range(5):
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_SEND_TOO_FREQUENT
|
||||
assert len(sender.sent) == 5
|
||||
|
||||
|
||||
async def test_daily_limit_eleventh_request_rejected() -> None:
|
||||
# 同邮箱 24h ≤ 10:第 11 次 42913(小时上限抬高以单独验证日窗)
|
||||
service, _, sender, _ = _service(_settings(email_hourly_limit=99, email_daily_limit=10))
|
||||
for _ in range(10):
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_DAILY_LIMIT
|
||||
assert len(sender.sent) == 10
|
||||
|
||||
|
||||
async def test_ip_dimension_twenty_first_rejected() -> None:
|
||||
# 同 IP 24h ≤ 20:每次换新邮箱 + 新设备键(设备维度缺省回退 IP,不隔离会
|
||||
# 先撞设备上限 10——这正是「缺省用 IP」的语义),第 21 次 42913
|
||||
service, _, _, _ = _service(_settings(email_ip_daily_limit=20))
|
||||
for i in range(20):
|
||||
await service.request_code(f"user{i}@example.com", ip="9.9.9.9", device_id=f"dev-{i}")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code("other@example.com", ip="9.9.9.9", device_id="dev-x")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_DAILY_LIMIT
|
||||
|
||||
|
||||
async def test_device_dimension_eleventh_rejected() -> None:
|
||||
# 同设备 24h ≤ 10:第 11 次 42913(邮箱与 IP 都逐次更换以隔离维度)
|
||||
service, _, _, _ = _service(_settings(email_device_daily_limit=10))
|
||||
for i in range(10):
|
||||
await service.request_code(f"user{i}@example.com", ip=f"10.0.0.{i}", device_id="device-a")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code("other@example.com", ip="10.0.0.99", device_id="device-a")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_DAILY_LIMIT
|
||||
|
||||
|
||||
async def test_device_dimension_falls_back_to_ip() -> None:
|
||||
# 不传 device_id 时该维度以 IP 兜底计数:同 IP 打 11 个邮箱,第 11 次 42913
|
||||
service, _, _, _ = _service(_settings(email_device_daily_limit=10, email_ip_daily_limit=99))
|
||||
for i in range(10):
|
||||
await service.request_code(f"user{i}@example.com", ip="10.0.0.1")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code("other@example.com", ip="10.0.0.1")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_DAILY_LIMIT
|
||||
|
||||
|
||||
async def test_cooldown_blocks_resend_within_window_and_allows_after() -> None:
|
||||
# 同邮箱 60s 冷却:期内二次 42912,过窗放行(小时上限抬高排除干扰)
|
||||
service, _, sender, clock = _service(
|
||||
_settings(email_resend_cooldown_seconds=60, email_hourly_limit=99)
|
||||
)
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_SEND_TOO_FREQUENT
|
||||
clock.advance(61)
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
assert len(sender.sent) == 2
|
||||
|
||||
|
||||
async def test_sender_failure_maps_to_provider_failed() -> None:
|
||||
# 通道故障 → 61001(外部依赖错误,不与内部 5xxxx 混淆)
|
||||
service = EmailAuthService(InMemoryEmailStore(), FailingSender(), settings=_settings())
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_PROVIDER_FAILED
|
||||
|
||||
|
||||
# --- 校验:验证码生命周期 -----------------------------------------------------
|
||||
|
||||
|
||||
async def test_verify_success_is_one_time() -> None:
|
||||
# 一次性:成功即删,同一码二次使用按「已过期」回应
|
||||
service, _, sender, _ = _service(_settings())
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
code = _last_code(sender)
|
||||
assert await service.verify(EMAIL, code) == email_union_id(EMAIL)
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(EMAIL, code)
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_CODE_EXPIRED
|
||||
|
||||
|
||||
async def test_verify_expired_after_ttl() -> None:
|
||||
# 5 分钟 TTL:过窗后校验 40113
|
||||
service, _, sender, clock = _service(_settings(email_code_ttl_seconds=300))
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
clock.advance(301)
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(EMAIL, _last_code(sender))
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_CODE_EXPIRED
|
||||
|
||||
|
||||
async def test_four_wrong_attempts_still_allowed_then_correct_code_passes() -> None:
|
||||
# 失败 5 次作废:错 4 次仍可再试,第 5 次尝试用对码可以通过
|
||||
service, _, sender, _ = _service(_settings())
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
wrong = _wrong_code(_last_code(sender))
|
||||
for _ in range(4):
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(EMAIL, wrong)
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_CODE_INVALID
|
||||
assert await service.verify(EMAIL, _last_code(sender)) == email_union_id(EMAIL)
|
||||
|
||||
|
||||
async def test_fifth_wrong_attempt_invalidates_code() -> None:
|
||||
# 第 5 次错误:返回 40112 且码已删——之后连正确码都只剩 40113
|
||||
service, _, sender, _ = _service(_settings())
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
correct = _last_code(sender)
|
||||
for _ in range(5):
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(EMAIL, _wrong_code(correct))
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_CODE_INVALID
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(EMAIL, correct)
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_CODE_EXPIRED
|
||||
|
||||
|
||||
async def test_new_code_resets_attempt_counter() -> None:
|
||||
# 「失败 5 次」按码计不按邮箱计:换新码后失败计数清零
|
||||
service, _, sender, _ = _service(_settings(email_hourly_limit=99))
|
||||
await service.request_code(EMAIL, ip="1.1.1.1")
|
||||
for _ in range(4):
|
||||
with pytest.raises(AppError):
|
||||
await service.verify(EMAIL, _wrong_code(_last_code(sender)))
|
||||
await service.request_code(EMAIL, ip="1.1.1.1") # cooldown=0 可立即重发
|
||||
assert await service.verify(EMAIL, _last_code(sender)) == email_union_id(EMAIL)
|
||||
|
||||
|
||||
async def test_verify_unknown_email_is_expired() -> None:
|
||||
# 从未发过码的邮箱:40113(不区分「无码」细节,防状态探测)
|
||||
service, _, _, _ = _service(_settings())
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(EMAIL, "123456")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_CODE_EXPIRED
|
||||
|
||||
|
||||
# --- 通道装配与 SMTP 实现(不连网:桩替真实连接)-------------------------------
|
||||
|
||||
|
||||
def test_build_service_fake_provider() -> None:
|
||||
service = build_email_auth_service(_settings(email_provider="fake"), object())
|
||||
assert isinstance(service._sender, FakeEmailSender)
|
||||
|
||||
|
||||
def test_build_service_unknown_provider_is_config_error() -> None:
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
build_email_auth_service(_settings(email_provider="pigeon"), object())
|
||||
assert excinfo.value.code == ErrorCode.CONFIG_ERROR
|
||||
|
||||
|
||||
def test_build_service_smtp_without_config_maps_61001() -> None:
|
||||
# 构造失败(SMTP 配置缺失)→ 61001:装配期就炸,不带病上岗
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
build_email_auth_service(_settings(email_provider="smtp"), object())
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_PROVIDER_FAILED
|
||||
|
||||
|
||||
_SMTP_SETTINGS: dict[str, Any] = {
|
||||
"email_provider": "smtp",
|
||||
"smtp_host": "smtp.example.com",
|
||||
"smtp_user": "noreply@example.com",
|
||||
"smtp_pass": "secret",
|
||||
}
|
||||
|
||||
|
||||
class _RecordingSmtpSender(SmtpEmailSender):
|
||||
"""替换 _send_sync 为记录调用(不碰网络)的桩实现。"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
super().__init__(settings)
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
||||
def _send_sync(self, email: str, code: str) -> None:
|
||||
self.calls.append((email, code))
|
||||
|
||||
|
||||
async def test_smtp_sender_builds_message_and_accepts_success() -> None:
|
||||
sender = _RecordingSmtpSender(_settings(**_SMTP_SETTINGS))
|
||||
await sender.send_code(EMAIL, MASKED, "123456")
|
||||
assert sender.calls == [(EMAIL, "123456")]
|
||||
|
||||
|
||||
class _ExplodingSmtpSender(SmtpEmailSender):
|
||||
def _send_sync(self, email: str, code: str) -> None:
|
||||
raise TimeoutError("connect timeout")
|
||||
|
||||
|
||||
async def test_smtp_sender_network_error_maps_61001() -> None:
|
||||
sender = _ExplodingSmtpSender(_settings(**_SMTP_SETTINGS))
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await sender.send_code(EMAIL, MASKED, "123456")
|
||||
assert excinfo.value.code == ErrorCode.EMAIL_PROVIDER_FAILED
|
||||
Reference in New Issue
Block a user