Files
meida_backend/tests/unit/test_email_login.py
T

392 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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