392 lines
15 KiB
Python
392 lines
15 KiB
Python
"""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
|