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,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