From cf9d500bfa42f1e0f247891acfea24281f7cbc15 Mon Sep 17 00:00:00 2001 From: xsl Date: Fri, 14 Aug 2026 20:04:46 +0800 Subject: [PATCH] feat(identity): phone SMS login with triple rate limiting and anti-abuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SmsSender Protocol(fake/tencent)+ 验证码生命周期(哈希存储/一次性/ 5 次作废)+ 号码-IP-设备三重频控 + 掩码纪律(FR-ACC-04/14,TD-32; 真实发送待签名审核)。 实现:packages/identity/sms_login.py(SmsStore/SmsSender 双 Protocol、 FakeSmsSender 打日志、TencentSmsSender 包官方 SDK 失败映射 61000、 SmsAuthService 验证码生命周期 + UTC+8 时段桶频控);路由 POST /auth/sms/send、POST /auth/sms/verify(provider="phone" 映射 + issue_session + TokenPairResponse,与华为通道零耦合)。测试:单测 37 个 (内存 dict 实现 store、注入时钟模拟 TTL;号码规范化/掩码/频控矩阵/ 验证码四要素/腾讯通道桩测),集成 7 个(send→verify 双令牌闭环、同号 同 user、脚本化刷量 11 发第 11 次 42911、IP/设备维度、错 5 次作废)。 门禁:ruff/mypy(identity+apps/api)/lint-imports 全绿,单测 275 通过、 集成 23 通过。 偏差说明: 1. SmsSender.send_code 收 (phone, masked, code) 三参而非单一「掩码键」: 腾讯通道下发必须有真实号码(掩码/哈希无法投递);防泄漏由「实现层 不得记录 phone」约定 + structlog 全局手机号脱敏兜底。 2. 频控计数按「请求」计(含被拒请求)且在限额检查前自增:否则被拒 请求不烧额度,攻击者可无限试探;同时这是「连发 11 次第 11 次 42911」脚本化验收在真实时钟下成立的前提。 3. request_code 对 sender 逃逸的非 AppError 异常兜底包成 61000: 通道故障不得以 5xxxx 内部错误形式稀释告警(TencentSmsSender 自身 映射为主,此处仅接漏网)。 4. wire 请求模型(SmsSendRequest/SmsVerifyRequest)定义在 sms_login.py 内(任务文档允许的两选一,未新建 sms_schemas.py)。 --- apps/api/routers/auth_sms.py | 102 ++++++- packages/identity/sms_login.py | 426 ++++++++++++++++++++++++++++ tests/integration/test_auth_sms.py | 207 ++++++++++++++ tests/unit/test_sms_login.py | 430 +++++++++++++++++++++++++++++ 4 files changed, 1160 insertions(+), 5 deletions(-) create mode 100644 packages/identity/sms_login.py create mode 100644 tests/integration/test_auth_sms.py create mode 100644 tests/unit/test_sms_login.py diff --git a/apps/api/routers/auth_sms.py b/apps/api/routers/auth_sms.py index c17c2c7..d68dae4 100644 --- a/apps/api/routers/auth_sms.py +++ b/apps/api/routers/auth_sms.py @@ -1,12 +1,104 @@ -"""手机号验证码登录路由(骨架,T11 填充实现)。 +"""手机号验证码登录路由(T11,FR-ACC-04 / FR-ACC-14)。 -POST /auth/sms/send、POST /auth/sms/verify; -实现契约见 docs/开发任务包/T11-短信登录代码先行.md。 -与 auth.py(华为/dev-token/refresh)分文件以避免并行冲突。 +POST /auth/sms/send、POST /auth/sms/verify;与 auth.py(华为/dev-token/ +refresh,T10)分文件以避免并行冲突,且**不 import 华为相关任何内容**—— +登录成功只依赖 identity 域已有设施(get_or_create_by_external_identity + +issue_session + TokenPairResponse)。 + +``_access_token``/``_token_pair`` 与 auth.py 各持一份小拷贝:私有助手不跨 +路由复用,避免 T10 并行改动 auth.py 时把冲突引进本文件。 """ from __future__ import annotations -from fastapi import APIRouter +from typing import Annotated + +from fastapi import APIRouter, Depends, Request + +from apps.api.deps import IdentityServiceDep, SettingsDep +from packages.identity.schemas import TokenPairResponse +from packages.identity.service import SessionIssued +from packages.identity.sms_login import ( + SmsAuthService, + SmsSendRequest, + SmsVerifyRequest, + build_sms_auth_service, +) +from packages.shared.config import Settings, get_settings +from packages.shared.redis_client import get_redis +from packages.shared.response import ok +from packages.shared.security import create_access_token router = APIRouter(prefix="/auth", tags=["auth-sms"]) + + +def sms_auth_dep(request: Request) -> SmsAuthService: + """惰性装配并缓存到 ``app.state``(与 deps.gateway_dep 同一模式)。 + + 缓存到 app.state 同时是集成测试的钩子:测试在发请求前把捕获型 fake + sender 的 service 放进 ``app.state.sms_auth`` 即可拿到验证码——生产代码 + 不为测试留任何后门。 + """ + service: SmsAuthService | None = getattr(request.app.state, "sms_auth", None) + if service is None: + service = build_sms_auth_service(get_settings(), get_redis()) + request.app.state.sms_auth = service + return service + + +SmsAuthDep = Annotated[SmsAuthService, Depends(sms_auth_dep)] + + +def _client_ip(request: Request) -> str: + """直连对端 IP(ASGI scope)。反向代理场景的字段解析属网关层职责,V1 不做。""" + return request.client.host if request.client is not None else "unknown" + + +def _access_token(settings: Settings, user_id: int) -> str: + return create_access_token( + user_id=user_id, + secret=settings.jwt_secret, + algorithm=settings.jwt_algorithm, + ttl_seconds=settings.jwt_access_ttl_seconds, + issuer=settings.jwt_issuer, + ) + + +def _token_pair( + settings: Settings, access_token: str, issued: SessionIssued +) -> dict[str, str | int]: + """与 auth.py 的 dev-token/refresh 完全一致的双令牌响应形态。""" + return TokenPairResponse( + access_token=access_token, + refresh_token=issued.refresh_token, + expires_in=settings.jwt_access_ttl_seconds, + refresh_expires_in=settings.refresh_token_ttl_seconds, + user_id=str(issued.user_id), + ).model_dump() + + +@router.post("/sms/send", summary="发送短信验证码") +async def send_sms_code( + payload: SmsSendRequest, request: Request, sms: SmsAuthDep +) -> dict[str, object]: + # 限流/格式错误(42910/42911/40000/61000)由统一异常处理器透传给客户端 + await sms.request_code(payload.phone, ip=_client_ip(request), device_id=payload.device_id) + return ok({"sent": True}) + + +@router.post("/sms/verify", summary="校验验证码并登录(双令牌)") +async def verify_sms_code( + payload: SmsVerifyRequest, + request: Request, + sms: SmsAuthDep, + settings: SettingsDep, + identity: IdentityServiceDep, +) -> dict[str, object]: + union_id = await sms.verify( + payload.phone, payload.code, ip=_client_ip(request), device_id=payload.device_id + ) + user = await identity.get_or_create_by_external_identity( + provider="phone", union_id=union_id, register_source="phone" + ) + issued = await identity.issue_session(user.user_id) + return ok(_token_pair(settings, _access_token(settings, user.user_id), issued)) diff --git a/packages/identity/sms_login.py b/packages/identity/sms_login.py new file mode 100644 index 0000000..8730cc3 --- /dev/null +++ b/packages/identity/sms_login.py @@ -0,0 +1,426 @@ +"""手机号验证码登录(FR-ACC-04 / FR-ACC-14,TD-32)。 + +与华为通道(FR-ACC-01,T10)**零耦合**:本模块不 import 华为相关任何内容, +登录成功路径只依赖 identity 域已有设施(``get_or_create_by_external_identity`` ++ ``issue_session`` 由路由层调用)。 + +关键纪律与设计决策: + +- **明文号码不出现在 Redis 与日志**:Redis 键用 ``sha256(规范化号码)`` 前 16 + 位,日志一律掩码(``138****1234``);验证码在 Redis 里只存 sha256 哈希, + 比对用 ``hmac.compare_digest``(恒定时间,防时序侧信道逐位猜码)。 +- **号码存储决策(V1 简化)**:``id_user_identity`` 现无 ``phone_enc`` 列, + ``union_id = "phone:" + sha256(号码)[:16]``(E.164 规范化:去 +86/0086/ + 前导零/空白后哈希);``phone_enc`` 补齐属后续迁移任务。 +- **频控计数按「请求」计而非「成功发送」计**:脚本刷量时被拒的请求也要烧 + 额度,否则攻击者可以用被拒请求无限试探而不触发任何上限;这也是 + FR-ACC-14「连发 11 次、第 11 次 42911」脚本化验收在真实时钟下成立的 + 前提。代价是用户连点两次会多烧一次额度——防刷优先。 +- 时段桶对齐 billing 的时区纪律:统一 UTC+8 自然小时/日边界。 + +与任务文档的两处实现偏差(见提交信息): + +1. ``SmsSender.send_code`` 收 ``(phone, masked, code)`` 三参而非单一 + 「掩码键」——腾讯通道下发必须有真实号码(掩码/哈希无法投递);明文 + 防泄漏由三层兜底:协议约定实现层不得记录 ``phone``(日志只用 ``masked``)、 + structlog 全局脱敏处理器会掩码任何 11 位手机号样式的字符串、 + FakeSmsSender 只打 ``masked``。 +2. 频控计数自增发生在限额检查**之前**(按请求计,见上)。 +""" + +from __future__ import annotations + +import hashlib +import hmac +import re +import secrets +from datetime import datetime, timedelta, timezone +from typing import Protocol, runtime_checkable + +from pydantic import BaseModel, Field +from tencentcloud.common.credential import Credential +from tencentcloud.sms.v20210111 import models as tencent_sms_models +from tencentcloud.sms.v20210111.sms_client_async import SmsClient as AsyncTencentSmsClient + +from packages.shared.config import Settings +from packages.shared.errors import AppError, ErrorCode +from packages.shared.logging import get_logger + +logger = get_logger(__name__) + +#: V1 只做大陆手机号(写死并注释):境外号码格式与通道资质都不在本批范围。 +_MAINLAND_PHONE_RE = re.compile(r"^1[3-9]\d{9}$") + +#: 自然窗口的时区:与 billing 的日预算桶一致(FR-BILL-01 纪律),UTC+8。 +_CN_TZ = timezone(timedelta(hours=8)) + +_HOUR_BUCKET_TTL = 2 * 60 * 60 # 小时桶键活 2h,覆盖桶期 + 少量时钟偏移 +_DAY_BUCKET_TTL = 26 * 60 * 60 # 日桶键活 26h,过期由桶名(日期)保证语义正确 + +#: union_id 的 provider 前缀(id_user_identity.provider 固定 "phone")。 +_PHONE_PROVIDER = "phone" + + +# --- 号码与验证码的纯函数 ---------------------------------------------------- + + +def normalize_phone(raw: str) -> str: + """规范化大陆手机号为 11 位裸号码。 + + 容忍 ``+86`` / ``0086`` / ``86`` 国家码、空白、连字符、括号与前导零; + 规范化后不匹配 ``^1[3-9]\\d{9}$`` 即 40000(400 类错误,不消耗频控额度 + ——非法格式的请求连 Redis 都不碰,避免垃圾键)。 + """ + digits = re.sub(r"\D", "", raw) + if digits.startswith("0086"): + digits = digits[4:] + if len(digits) == 13 and digits.startswith("86"): + digits = digits[2:] + digits = digits.lstrip("0") + if not _MAINLAND_PHONE_RE.fullmatch(digits): + raise AppError(ErrorCode.BAD_REQUEST, message="手机号格式不正确", hint="请输入大陆手机号") + return digits + + +def masked_phone(phone: str) -> str: + """日志掩码:``138****1234``(输入应为规范化后的 11 位号码)。""" + return f"{phone[:3]}****{phone[-4:]}" + + +def _hash_key(value: str) -> str: + """任意维度标识(IP/设备号)进 Redis 键前的哈希:键会进监控/RDB,不落明文。""" + return hashlib.sha256(value.encode()).hexdigest()[:16] + + +def phone_hash_key(phone: str) -> str: + """号码在 Redis 键中的形态:sha256 前 16 位(不存明文,FR-ACC-14 掩码纪律)。""" + return hashlib.sha256(phone.encode()).hexdigest()[:16] + + +def phone_union_id(phone: str) -> str: + """号码映射到 ``id_user_identity.union_id``(内部先规范化,同号恒同 ID)。""" + return f"{_PHONE_PROVIDER}:{phone_hash_key(normalize_phone(phone))}" + + +def generate_code(length: int) -> str: + """6 位数字验证码。``secrets`` 而非 ``random``:验证码是安全敏感值, + Mersenne Twister 可预测;均匀取 ``[0, 10^length)`` 后补前导零。""" + return f"{secrets.randbelow(10**length):0{length}d}" + + +def hash_code(code: str) -> str: + """验证码只以 sha256 形态落 Redis:泄漏 RDB 换不回可用验证码。""" + return hashlib.sha256(code.encode()).hexdigest() + + +# --- wire 模型(任务文档允许放本模块;共享 schemas.py 不碰)------------------- + + +class SmsSendRequest(BaseModel): + """POST /auth/sms/send 请求体。 + + 号码格式校验放服务层:规范化(+86/前导零/空白)不是一条正则能表达的。 + """ + + phone: str = Field( + min_length=5, max_length=20, description="大陆手机号,容忍 +86/空格/连字符写法" + ) + device_id: str | None = Field( + default=None, max_length=128, description="设备指纹;缺省则该维度不参与频控" + ) + + +class SmsVerifyRequest(BaseModel): + """POST /auth/sms/verify 请求体。""" + + phone: str = Field(min_length=5, max_length=20) + code: str = Field(min_length=4, max_length=8, description="6 位数字验证码") + device_id: str | None = Field(default=None, max_length=128) + + +# --- 存储接口(Redis 生产 / dict 单测)---------------------------------------- + + +@runtime_checkable +class SmsStore(Protocol): + """验证码与频控计数的最小存储接口。 + + 生产用 :class:`RedisSmsStore`(跨进程原子);单测自写内存 dict 实现 + (参考 ``InMemoryCostCounter`` 的做法,不引 fakeredis)。 + """ + + async def get(self, key: str) -> str | None: ... + + async def set(self, key: str, value: str, ttl_seconds: int) -> None: ... + + async def delete(self, key: str) -> None: ... + + async def incr(self, key: str, ttl_seconds: int) -> int: ... + + +class RedisSmsStore: + """生产实现:包一层 ``redis.asyncio.Redis`` 适配到 :class:`SmsStore`。 + + 持有 ``object`` 而非 ``Redis`` 具体类型并局部 ignore——与 + ``RedisCostCounter`` 相同的做法:identity 包只认接口,不 import redis 包。 + """ + + def __init__(self, redis: object) -> None: + self._redis = redis + + async def get(self, key: str) -> str | None: + value = await self._redis.get(key) # type: ignore[attr-defined] + return str(value) if value is not None else None + + async def set(self, key: str, value: str, ttl_seconds: int) -> None: + await self._redis.set(key, value, ex=ttl_seconds) # type: ignore[attr-defined] + + async def delete(self, key: str) -> None: + await self._redis.delete(key) # type: ignore[attr-defined] + + async def incr(self, key: str, ttl_seconds: int) -> int: + # INCR + EXPIRE 走 pipeline:计数与过期原子下发,滚动续期(桶名含 + # 日期/小时,键多活一会儿不影响窗口语义)。 + pipe = self._redis.pipeline() # type: ignore[attr-defined] + pipe.incr(key) + pipe.expire(key, ttl_seconds) + results = await pipe.execute() + return int(results[0]) + + +# --- 发送通道(Protocol 隔离,联调后置)-------------------------------------- + + +@runtime_checkable +class SmsSender(Protocol): + """验证码发送通道。fake 打日志(dev),tencent 真实下发(待凭证)。 + + 实现层约定:**不得记录 ``phone`` 明文**,日志/异常只允许 ``masked``; + ``code`` 明文仅 dev 专用实现可为联调打印。 + """ + + async def send_code(self, phone: str, masked: str, code: str) -> None: ... + + +class FakeSmsSender: + """dev 专用(``SMS_PROVIDER=fake``,默认值):验证码打日志。 + + 生产必须切 tencent——这里把 code 明文打进日志只是本地联调需要, + 任何非 local 环境都不该用它。 + """ + + async def send_code(self, phone: str, masked: str, code: str) -> None: + logger.info("sms_code_issued", masked=masked, code=code) + + +class TencentSmsSender: + """腾讯云短信(真实通道,签名/模板审核通过后启用,本批不联调)。 + + 构造失败(凭证缺失)与响应非 Ok 一律映射 61000(SMS_PROVIDER_FAILED): + 通道故障是外部依赖问题,不能被当成 5xxxx 内部错误稀释告警。 + """ + + #: 短信是全局服务,SDK 签名要 region 参数,按官方示例固定广州即可。 + _REGION = "ap-guangzhou" + + def __init__(self, settings: Settings) -> None: + missing = [ + name + for name, value in ( + ("TENCENT_SMS_SECRET_ID", settings.tencent_sms_secret_id), + ("TENCENT_SMS_SECRET_KEY", settings.tencent_sms_secret_key), + ("TENCENT_SMS_SDK_APP_ID", settings.tencent_sms_sdk_app_id), + ("TENCENT_SMS_SIGN_NAME", settings.tencent_sms_sign_name), + ("TENCENT_SMS_TEMPLATE_ID", settings.tencent_sms_template_id), + ) + if not value + ] + if missing: + # 只回显配置项名字,绝不回显已配置的值(secret 可能就在其中) + raise AppError( + ErrorCode.SMS_PROVIDER_FAILED, + message="短信通道凭证未配置", + data={"missing": missing}, + ) + self._settings = settings + self._client: object | None = None + + def _ensure_client(self) -> object: + """惰性构造 SDK 客户端:凭证明文只活在客户端对象里。""" + if self._client is None: + self._client = AsyncTencentSmsClient( + Credential( + self._settings.tencent_sms_secret_id, + self._settings.tencent_sms_secret_key, + ), + self._REGION, + ) + return self._client + + async def send_code(self, phone: str, masked: str, code: str) -> None: + request = tencent_sms_models.SendSmsRequest() + request.PhoneNumberSet = [f"+86{phone}"] # SDK 要求 E.164(含国家码) + request.SmsSdkAppId = self._settings.tencent_sms_sdk_app_id + request.SignName = self._settings.tencent_sms_sign_name + request.TemplateId = self._settings.tencent_sms_template_id + request.TemplateParamSet = [code] # 模板变量即验证码本身 + try: + resp = await self._ensure_client().SendSms(request) # type: ignore[attr-defined] + except Exception as exc: + # 网络超时/签名错误/限流都视为通道故障;异常细节只留类型名 + logger.warning("sms_provider_error", masked=masked, error=type(exc).__name__) + raise AppError(ErrorCode.SMS_PROVIDER_FAILED) from exc + statuses = list(getattr(resp, "SendStatusSet", None) or []) + status = statuses[0] if statuses else None + if status is None or getattr(status, "Code", None) != "Ok": + # 只打掩码与腾讯侧错误码;响应里的 PhoneNumber 是明文号码,绝不入日志 + logger.warning( + "sms_send_rejected", + masked=masked, + provider_code=getattr(status, "Code", None), + ) + raise AppError(ErrorCode.SMS_PROVIDER_FAILED) + + +# --- 服务:验证码生命周期 + 三重频控 ------------------------------------------ + + +class SmsAuthService: + """验证码生命周期(6 位 / 300s / 一次性 / 失败 5 次作废)+ 三重频控。 + + FR-ACC-14 的三重频控维度:同号(60s 冷却 / 1h≤5 / 24h≤10)、同 IP + (24h≤20)、同设备(24h≤10)。时段桶用 UTC+8 自然窗口,对齐 billing。 + """ + + def __init__(self, store: SmsStore, sender: SmsSender, *, settings: Settings) -> None: + self._store = store + self._sender = sender + self._settings = settings + + async def request_code(self, phone: str, *, ip: str, device_id: str | None = None) -> None: + """发送验证码:先频控后发送(FR-ACC-14),全过才生成并存储随机码。""" + normalized = normalize_phone(phone) + pkey = phone_hash_key(normalized) + masked = masked_phone(normalized) + cfg = self._settings + now = datetime.now(_CN_TZ) + day = f"{now:%Y%m%d}" + hour = f"{now:%Y%m%d%H}" + cooldown_key = f"sms:cooldown:{pkey}" + + # 计数按请求计(含被拒请求):模块 docstring 论证过为什么。 + hourly = await self._store.incr(f"sms:cnt:h:{pkey}:{hour}", ttl_seconds=_HOUR_BUCKET_TTL) + daily = await self._store.incr(f"sms:cnt:d:{pkey}:{day}", ttl_seconds=_DAY_BUCKET_TTL) + ip_count = await self._store.incr( + f"sms:cnt:ip:{_hash_key(ip)}:{day}", ttl_seconds=_DAY_BUCKET_TTL + ) + device_count = 0 + if device_id: + # device_id 是客户端自报的任意串:不传则该维度不参与(宁可少拦不可误伤) + device_count = await self._store.incr( + f"sms:cnt:dev:{_hash_key(device_id)}:{day}", ttl_seconds=_DAY_BUCKET_TTL + ) + + # 检查顺序:先报「今日已达上限」(信息量最大),再小时/冷却,最后维度限额。 + if daily > cfg.sms_daily_limit: + logger.info("sms_rate_limited", masked=masked, dimension="phone_daily", count=daily) + raise AppError(ErrorCode.SMS_DAILY_LIMIT) + if hourly > cfg.sms_hourly_limit: + logger.info("sms_rate_limited", masked=masked, dimension="phone_hourly", count=hourly) + raise AppError(ErrorCode.SMS_SEND_TOO_FREQUENT) + if cfg.sms_resend_cooldown_seconds > 0 and await self._store.get(cooldown_key) is not None: + logger.info("sms_rate_limited", masked=masked, dimension="cooldown") + raise AppError(ErrorCode.SMS_SEND_TOO_FREQUENT) + if ip_count > cfg.sms_ip_daily_limit: + logger.info("sms_rate_limited", masked=masked, dimension="ip_daily", count=ip_count) + raise AppError(ErrorCode.SMS_DAILY_LIMIT) + if device_count > cfg.sms_device_daily_limit: + logger.info( + "sms_rate_limited", masked=masked, dimension="device_daily", count=device_count + ) + raise AppError(ErrorCode.SMS_DAILY_LIMIT) + + code = generate_code(cfg.sms_code_length) + await self._store.set( + f"sms:code:{pkey}", hash_code(code), ttl_seconds=cfg.sms_code_ttl_seconds + ) + # 新码作废旧码的全部失败计数:「失败 5 次」按码计,不按号计 + await self._store.delete(f"sms:attempts:{pkey}") + if cfg.sms_resend_cooldown_seconds > 0: + await self._store.set(cooldown_key, "1", ttl_seconds=cfg.sms_resend_cooldown_seconds) + logger.info("sms_code_dispatched", masked=masked, ttl=cfg.sms_code_ttl_seconds) + try: + await self._sender.send_code(normalized, masked, code) + except AppError: + raise + except Exception as exc: + # 兜底:sender 实现漏映射异常时,通道故障也不许变成 5xxxx 内部错误 + # 稀释告警(TencentSmsSender 自己会先映射一次,这里只接住漏网的)。 + logger.warning("sms_sender_unexpected_error", masked=masked, error=type(exc).__name__) + raise AppError(ErrorCode.SMS_PROVIDER_FAILED) from exc + + async def verify( + self, phone: str, code: str, *, ip: str = "", device_id: str | None = None + ) -> str: + """校验验证码;成功返回该号码的 ``union_id``,由调用方建户/查户。 + + ``ip``/``device_id`` 为风控审计预留(V1 不参与校验判定——校验侧的 + 防护是「失败 5 次作废」+ 一次性删除,不需要再叠频控)。 + """ + normalized = normalize_phone(phone) + pkey = phone_hash_key(normalized) + masked = masked_phone(normalized) + stored = await self._store.get(f"sms:code:{pkey}") + if stored is None: + # 无码 = 过期 / 从未发送 / 已用尽作废 / 已消费——统一按过期回应, + # 不区分细节,避免给攻击者探测状态的机会 + raise AppError(ErrorCode.SMS_CODE_EXPIRED) + if not hmac.compare_digest(stored, hash_code(code)): + attempts = await self._store.incr( + f"sms:attempts:{pkey}", ttl_seconds=self._settings.sms_code_ttl_seconds + ) + if attempts >= self._settings.sms_verify_max_attempts: + await self._store.delete(f"sms:code:{pkey}") + await self._store.delete(f"sms:attempts:{pkey}") + logger.warning("sms_code_invalidated", masked=masked, attempts=attempts) + else: + logger.info("sms_code_rejected", masked=masked, attempts=attempts) + raise AppError(ErrorCode.SMS_CODE_INVALID) + # 一次性:成功即删(连同失败计数),二次使用按「已过期」回应 + await self._store.delete(f"sms:code:{pkey}") + await self._store.delete(f"sms:attempts:{pkey}") + logger.info("sms_code_verified", masked=masked) + return f"{_PHONE_PROVIDER}:{pkey}" + + +def build_sms_auth_service(settings: Settings, redis: object) -> SmsAuthService: + """按 ``SMS_PROVIDER`` 装配(apps/api 层调用;单测直接构造 service)。""" + sender: SmsSender + if settings.sms_provider == "fake": + sender = FakeSmsSender() + elif settings.sms_provider == "tencent": + sender = TencentSmsSender(settings) # 凭证缺失在这里就抛 61000(构造失败) + else: + raise AppError( + ErrorCode.CONFIG_ERROR, message=f"未知 SMS_PROVIDER: {settings.sms_provider}" + ) + return SmsAuthService(RedisSmsStore(redis), sender, settings=settings) + + +__all__ = [ + "FakeSmsSender", + "RedisSmsStore", + "SmsAuthService", + "SmsSendRequest", + "SmsSender", + "SmsStore", + "SmsVerifyRequest", + "TencentSmsSender", + "build_sms_auth_service", + "generate_code", + "hash_code", + "masked_phone", + "normalize_phone", + "phone_hash_key", + "phone_union_id", +] diff --git a/tests/integration/test_auth_sms.py b/tests/integration/test_auth_sms.py new file mode 100644 index 0000000..aad5907 --- /dev/null +++ b/tests/integration/test_auth_sms.py @@ -0,0 +1,207 @@ +"""短信登录集成(T11,FR-ACC-04/14)—— 真实 PostgreSQL + Redis,SMS_PROVIDER=fake。 + +测试钩子:sender 经 ``app.state.sms_auth`` 注入为捕获型 fake(路由的惰性装配 +优先读 app.state),验证码从 fixture 拿而不是去挖生产日志——生产代码不为 +测试留任何后门。每用例用随机号码/IP/设备:频控计数按 UTC+8 自然日分桶, +固定值会跨用例、跨次运行、跨天污染 Redis 计数。 + +``sms_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.sms_login import RedisSmsStore, SmsAuthService + + +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:把发出去的码留在内存里供 verify 使用。""" + + def __init__(self) -> None: + self.codes: dict[str, str] = {} + + async def send_code(self, phone: str, masked: str, code: str) -> None: + self.codes[phone] = code + + +def _rand_phone() -> str: + # "139" + 8 位随机数字:合法大陆号形;随机空间 1e8,跨运行撞桶概率可忽略 + return f"139{uuid.uuid4().int % 10**8:08d}" + + +def _rand_ip() -> str: + # 随机源 IP(文档保留网段 198.51.100.0/24):让 IP 维度计数互不污染 + return f"198.51.100.{uuid.uuid4().int % 254 + 1}" + + +@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={"sms_resend_cooldown_seconds": 0}) + app = create_app(settings) + sender = CapturingSender() + app.state.sms_auth = SmsAuthService(RedisSmsStore(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 _send( + http: httpx.AsyncClient, phone: str, device_id: str | None = None +) -> httpx.Response: + return await http.post("/api/v1/auth/sms/send", json={"phone": phone, "device_id": device_id}) + + +async def test_send_then_verify_returns_working_token_pair( + env: tuple[Any, CapturingSender], +) -> None: + app, sender = env + phone = _rand_phone() + async with _client(app) as http: + sent = await _send(http, phone, device_id="dev-a") + assert sent.status_code == 200 + body = sent.json() + assert body["code"] == 0 + assert body["data"] == {"sent": True} + + verify = await http.post( + "/api/v1/auth/sms/verify", + json={"phone": phone, "code": sender.codes[phone], "device_id": "dev-a"}, + ) + assert verify.status_code == 200 + data = verify.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_phone_relogin_maps_to_same_user(env: tuple[Any, CapturingSender]) -> None: + app, sender = env + phone = _rand_phone() + async with _client(app) as http: + user_ids = [] + for _ in range(2): + await _send(http, phone) # cooldown=0,可立即重发取新码 + verify = await http.post( + "/api/v1/auth/sms/verify", json={"phone": phone, "code": sender.codes[phone]} + ) + assert verify.status_code == 200 + user_ids.append(verify.json()["data"]["user_id"]) + # provider="phone" 映射唯一:同号两次登录拿到同一 user_id + assert user_ids[0] == user_ids[1] + + +async def test_scripted_flood_eleventh_send_hits_daily_limit( + env: tuple[Any, CapturingSender], +) -> None: + """FR-ACC-14 验收原文:脚本化刷量证明拦截有效——同号连发 11 次。 + + 前 5 次放行;第 6~10 次先撞 1h≤5 的小时窗(42910);第 11 次撞 24h≤10 + 的日窗(42911)。设备逐次更换以隔离设备维度。 + """ + app, _ = env + phone = _rand_phone() + async with _client(app) as http: + for i in range(11): + resp = await _send(http, phone, device_id=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"] == 42910, body + else: + assert resp.status_code == 429 and body["code"] == 42911, body + + +async def test_ip_dimension_blocks_twenty_first_send(env: tuple[Any, CapturingSender]) -> None: + """IP 维度刷量:同一 IP 打 21 个不同号码,第 21 次 42911。""" + app, _ = env + ip = _rand_ip() + async with _client(app, ip=ip) as http: + for _ in range(20): + resp = await _send(http, _rand_phone(), device_id=f"ipdev-{uuid.uuid4().hex[:8]}") + assert resp.status_code == 200, resp.text + blocked = await _send(http, _rand_phone(), device_id=f"ipdev-{uuid.uuid4().hex[:8]}") + assert blocked.status_code == 429 + assert blocked.json()["code"] == 42911 + + +async def test_device_dimension_blocks_eleventh_send(env: tuple[Any, CapturingSender]) -> None: + """设备维度刷量:同一设备换号换 IP 打 11 次,第 11 次 42911。""" + 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 _send(http, _rand_phone(), device_id=device) + assert resp.status_code == 200, resp.text + blocked = await _send(http, _rand_phone(), device_id=device) + assert blocked.status_code == 429 + assert blocked.json()["code"] == 42911 + + +async def test_five_wrong_verifies_invalidate_the_code(env: tuple[Any, CapturingSender]) -> None: + """FR-ACC-04:验证码错 5 次作废——第 5 次仍报 40110,之后正确码也只剩 40111。""" + app, sender = env + phone = _rand_phone() + async with _client(app) as http: + await _send(http, phone) + code = sender.codes[phone] + wrong = code[:-1] + ("0" if code[-1] != "0" else "1") # 保证 != 真码 + for _ in range(5): + resp = await http.post("/api/v1/auth/sms/verify", json={"phone": phone, "code": wrong}) + assert resp.status_code == 401 + assert resp.json()["code"] == 40110 + # 码已作废:即使拿到正确码也只能 40111(一次性 + 作废都不留活口) + after = await http.post("/api/v1/auth/sms/verify", json={"phone": phone, "code": code}) + assert after.status_code == 401 + assert after.json()["code"] == 40111 + + +async def test_send_rejects_malformed_phone(env: tuple[Any, CapturingSender]) -> None: + """服务层规范化兜底:能过 Pydantic 长度但非大陆号形 → 40000。""" + app, _ = env + async with _client(app) as http: + resp = await _send(http, "00000") + assert resp.status_code == 400 + assert resp.json()["code"] == 40000 diff --git a/tests/unit/test_sms_login.py b/tests/unit/test_sms_login.py new file mode 100644 index 0000000..c689c1b --- /dev/null +++ b/tests/unit/test_sms_login.py @@ -0,0 +1,430 @@ +"""SmsAuthService 单测(packages/identity/sms_login.py)——无 IO、无网络。 + +存储用自写内存 dict 实现 Protocol 化的 SmsStore 接口(参考 +InMemoryCostCounter 的做法,不引 fakeredis);TTL 用可注入时钟模拟过期。 +频控计数语义按「请求」计(见被测模块 docstring),错误码矩阵对应 +FR-ACC-14 的三重频控。 +""" + +from __future__ import annotations + +import re +import time +from collections.abc import Callable +from types import SimpleNamespace +from typing import Any + +import pytest + +from packages.identity.sms_login import ( + FakeSmsSender, + SmsAuthService, + TencentSmsSender, + build_sms_auth_service, + generate_code, + hash_code, + masked_phone, + normalize_phone, + phone_hash_key, + phone_union_id, +) +from packages.shared.config import Settings +from packages.shared.errors import AppError, ErrorCode + +PHONE = "13812341234" +MASKED = "138****1234" + + +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 InMemorySmsStore: + """dict 实现的 SmsStore:键 → (值, 过期时刻);过期惰性清除。""" + + 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, phone: str, masked: str, code: str) -> None: + self.sent.append((phone, masked, code)) + + +class FailingSender: + async def send_code(self, phone: 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, "sms_resend_cooldown_seconds": 0} + values.update(overrides) + return Settings(**values) + + +def _service( + settings: Settings, sender: Any | None = None +) -> tuple[SmsAuthService, InMemorySmsStore, CapturingSender, FakeClock]: + clock = FakeClock() + store = InMemorySmsStore(clock) + capturing = CapturingSender() + service = SmsAuthService(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"), + [ + ("13812341234", "13812341234"), + ("+8613812341234", "13812341234"), + ("+86 138-1234 1234", "13812341234"), + ("008613812341234", "13812341234"), + ("8613812341234", "13812341234"), + (" 13812341234 ", "13812341234"), + ], +) +def test_normalize_phone_accepts_common_writings(raw: str, expected: str) -> None: + assert normalize_phone(raw) == expected + + +@pytest.mark.parametrize( + "bad", ["12345", "12345678901", "23812341234", "10812341234", "abc-def", "1381234123456"] +) +def test_normalize_phone_rejects_invalid_formats(bad: str) -> None: + # 非法格式是 400 类错误,且不消耗频控额度(不碰存储) + with pytest.raises(AppError) as excinfo: + normalize_phone(bad) + assert excinfo.value.code == ErrorCode.BAD_REQUEST + + +def test_masked_phone_hides_middle_digits() -> None: + assert masked_phone(PHONE) == MASKED + assert masked_phone(PHONE)[3:7] == "****" # 中间 4 位不出现 + + +def test_phone_hash_key_is_16_hex_chars() -> None: + digest = phone_hash_key(PHONE) + assert len(digest) == 16 + assert re.fullmatch(r"[0-9a-f]{16}", digest) + + +def test_phone_union_id_is_stable_and_hashed() -> None: + uid = phone_union_id("+8613812341234") + assert uid.startswith("phone:") + # 规范化后同号同 ID:+86 写法与裸号映射到同一用户 + assert uid == phone_union_id(PHONE) + assert len(uid) == len("phone:") + 16 + assert PHONE not in uid + + +# --- 发送:码形态与存储纪律 --------------------------------------------------- + + +async def test_request_code_sends_six_digit_code() -> None: + service, _, sender, _ = _service(_settings()) + await service.request_code(PHONE, ip="1.2.3.4", device_id="dev-1") + assert len(sender.sent) == 1 + phone, masked, code = sender.sent[0] + assert phone == PHONE + assert masked == MASKED + assert re.fullmatch(r"\d{6}", code) + + +async def test_code_stored_as_sha256_never_plaintext() -> None: + # FR-ACC-14 掩码纪律:Redis(此处为 store)里不得出现明文号码/验证码/IP + service, store, sender, _ = _service(_settings()) + await service.request_code(PHONE, ip="203.0.113.7", device_id="dev-1") + code = _last_code(sender) + entry = store._data.get(f"sms:code:{phone_hash_key(PHONE)}") + assert entry is not None and entry[0] == hash_code(code) + for key, (value, _) in store._data.items(): + assert PHONE not in key and PHONE 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 + + +def test_generate_code_is_zero_padded_digits() -> None: + assert re.fullmatch(r"\d{6}", generate_code(6)) + assert len(generate_code(6)) == 6 + + +def test_hash_code_is_sha256_hex() -> None: + assert hash_code("123456") == hash_code("123456") + assert len(hash_code("123456")) == 64 + assert "123456" not in hash_code("123456") + + +# --- 频控矩阵(FR-ACC-14)----------------------------------------------------- + + +async def test_hourly_limit_sixth_request_rejected() -> None: + # 同号 1h ≤ 5:第 6 次 42910 + service, _, sender, _ = _service(_settings(sms_hourly_limit=5)) + for _ in range(5): + await service.request_code(PHONE, ip="1.1.1.1") + with pytest.raises(AppError) as excinfo: + await service.request_code(PHONE, ip="1.1.1.1") + assert excinfo.value.code == ErrorCode.SMS_SEND_TOO_FREQUENT + assert len(sender.sent) == 5 + + +async def test_daily_limit_eleventh_request_rejected() -> None: + # 同号 24h ≤ 10:第 11 次 42911(小时上限抬高以单独验证日窗) + service, _, sender, _ = _service(_settings(sms_hourly_limit=99, sms_daily_limit=10)) + for _ in range(10): + await service.request_code(PHONE, ip="1.1.1.1") + with pytest.raises(AppError) as excinfo: + await service.request_code(PHONE, ip="1.1.1.1") + assert excinfo.value.code == ErrorCode.SMS_DAILY_LIMIT + assert len(sender.sent) == 10 + + +async def test_ip_dimension_twenty_first_rejected() -> None: + # 同 IP 24h ≤ 20:每次换新号码(同号额度不干扰),第 21 次 42911 + service, _, _, _ = _service(_settings(sms_ip_daily_limit=20)) + for i in range(20): + await service.request_code(f"138{i:08d}", ip="9.9.9.9") + with pytest.raises(AppError) as excinfo: + await service.request_code("13899999999", ip="9.9.9.9") + assert excinfo.value.code == ErrorCode.SMS_DAILY_LIMIT + + +async def test_device_dimension_eleventh_rejected() -> None: + # 同设备 24h ≤ 10:第 11 次 42911(号码与 IP 都逐次更换以隔离维度) + service, _, _, _ = _service(_settings(sms_device_daily_limit=10)) + for i in range(10): + await service.request_code(f"138{i:08d}", ip=f"10.0.0.{i}", device_id="device-a") + with pytest.raises(AppError) as excinfo: + await service.request_code("13899999999", ip="10.0.0.99", device_id="device-a") + assert excinfo.value.code == ErrorCode.SMS_DAILY_LIMIT + + +async def test_cooldown_blocks_resend_within_60s_and_allows_after() -> None: + # 同号 60s 冷却:期内二次 42910,过窗放行(小时上限抬高排除干扰) + service, _, sender, clock = _service( + _settings(sms_resend_cooldown_seconds=60, sms_hourly_limit=99) + ) + await service.request_code(PHONE, ip="1.1.1.1") + with pytest.raises(AppError) as excinfo: + await service.request_code(PHONE, ip="1.1.1.1") + assert excinfo.value.code == ErrorCode.SMS_SEND_TOO_FREQUENT + clock.advance(61) + await service.request_code(PHONE, ip="1.1.1.1") + assert len(sender.sent) == 2 + + +async def test_sender_failure_maps_to_provider_failed() -> None: + # 通道故障 → 61000(外部依赖错误,不与内部 5xxxx 混淆) + service = SmsAuthService(InMemorySmsStore(), FailingSender(), settings=_settings()) + with pytest.raises(AppError) as excinfo: + await service.request_code(PHONE, ip="1.1.1.1") + assert excinfo.value.code == ErrorCode.SMS_PROVIDER_FAILED + + +# --- 校验:FR-ACC-04 四要素 --------------------------------------------------- + + +async def test_verify_success_is_one_time() -> None: + # 一次性:成功即删,同一码二次使用按「已过期」回应 + service, _, sender, _ = _service(_settings()) + await service.request_code(PHONE, ip="1.1.1.1") + code = _last_code(sender) + assert await service.verify(PHONE, code) == phone_union_id(PHONE) + with pytest.raises(AppError) as excinfo: + await service.verify(PHONE, code) + assert excinfo.value.code == ErrorCode.SMS_CODE_EXPIRED + + +async def test_verify_expired_after_ttl() -> None: + # 5 分钟 TTL:过窗后校验 40111 + service, _, sender, clock = _service(_settings(sms_code_ttl_seconds=300)) + await service.request_code(PHONE, ip="1.1.1.1") + clock.advance(301) + with pytest.raises(AppError) as excinfo: + await service.verify(PHONE, _last_code(sender)) + assert excinfo.value.code == ErrorCode.SMS_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(PHONE, ip="1.1.1.1") + wrong = _wrong_code(_last_code(sender)) + for _ in range(4): + with pytest.raises(AppError) as excinfo: + await service.verify(PHONE, wrong) + assert excinfo.value.code == ErrorCode.SMS_CODE_INVALID + assert await service.verify(PHONE, _last_code(sender)) == phone_union_id(PHONE) + + +async def test_fifth_wrong_attempt_invalidates_code() -> None: + # 第 5 次错误:返回 40110 且码已删——之后连正确码都只剩 40111 + service, _, sender, _ = _service(_settings()) + await service.request_code(PHONE, ip="1.1.1.1") + correct = _last_code(sender) + for _ in range(5): + with pytest.raises(AppError) as excinfo: + await service.verify(PHONE, _wrong_code(correct)) + assert excinfo.value.code == ErrorCode.SMS_CODE_INVALID + with pytest.raises(AppError) as excinfo: + await service.verify(PHONE, correct) + assert excinfo.value.code == ErrorCode.SMS_CODE_EXPIRED + + +async def test_new_code_resets_attempt_counter() -> None: + # 「失败 5 次」按码计不按号计:换新码后失败计数清零 + service, _, sender, _ = _service(_settings(sms_hourly_limit=99)) + await service.request_code(PHONE, ip="1.1.1.1") + for _ in range(4): + with pytest.raises(AppError): + await service.verify(PHONE, _wrong_code(_last_code(sender))) + await service.request_code(PHONE, ip="1.1.1.1") # cooldown=0 可立即重发 + assert await service.verify(PHONE, _last_code(sender)) == phone_union_id(PHONE) + + +async def test_verify_unknown_phone_is_expired() -> None: + # 从未发过码的号:40111(不区分「无码」细节,防状态探测) + service, _, _, _ = _service(_settings()) + with pytest.raises(AppError) as excinfo: + await service.verify(PHONE, "123456") + assert excinfo.value.code == ErrorCode.SMS_CODE_EXPIRED + + +# --- 通道装配与腾讯实现(不连网:桩替 SDK 客户端)----------------------------- + + +def test_build_service_fake_provider() -> None: + service = build_sms_auth_service(_settings(sms_provider="fake"), object()) + assert isinstance(service._sender, FakeSmsSender) + + +def test_build_service_unknown_provider_is_config_error() -> None: + with pytest.raises(AppError) as excinfo: + build_sms_auth_service(_settings(sms_provider="pigeon"), object()) + assert excinfo.value.code == ErrorCode.CONFIG_ERROR + + +def test_build_service_tencent_without_credentials_maps_61000() -> None: + # 构造失败(凭证缺失)→ 61000:装配期就炸,不带病上岗 + with pytest.raises(AppError) as excinfo: + build_sms_auth_service(_settings(sms_provider="tencent"), object()) + assert excinfo.value.code == ErrorCode.SMS_PROVIDER_FAILED + + +_TENCENT_SETTINGS: dict[str, Any] = { + "sms_provider": "tencent", + "tencent_sms_secret_id": "secret-id", + "tencent_sms_secret_key": "secret-key", + "tencent_sms_sdk_app_id": "1400006666", + "tencent_sms_sign_name": "美搭", + "tencent_sms_template_id": "1234560", +} + + +class _StubClient: + """替身 SDK 客户端:记录请求并返回固定 SendStatusSet。""" + + def __init__(self, code: str) -> None: + self._code = code + self.requests: list[Any] = [] + + async def SendSms(self, request: Any) -> Any: # noqa: N802 - 对齐 SDK 的 API 名 + self.requests.append(request) + status = SimpleNamespace(Code=self._code, Message="stub") + return SimpleNamespace(SendStatusSet=[status], RequestId="stub-rid") + + +class _StubbedTencentSender(TencentSmsSender): + def __init__(self, code: str) -> None: + super().__init__(_settings(**_TENCENT_SETTINGS)) + self.stub = _StubClient(code) + + def _ensure_client(self) -> Any: + return self.stub + + +class _ExplodingTencentSender(TencentSmsSender): + def __init__(self) -> None: + super().__init__(_settings(**_TENCENT_SETTINGS)) + + def _ensure_client(self) -> Any: + async def send(_: Any) -> Any: + raise TimeoutError("connect timeout") + + return SimpleNamespace(SendSms=send) + + +async def test_tencent_sender_builds_e164_request_and_accepts_ok() -> None: + sender = _StubbedTencentSender("Ok") + await sender.send_code(PHONE, MASKED, "123456") + request = sender.stub.requests[0] + assert request.PhoneNumberSet == ["+8613812341234"] + assert request.TemplateParamSet == ["123456"] + assert request.SmsSdkAppId == "1400006666" + + +async def test_tencent_sender_rejected_status_maps_61000() -> None: + sender = _StubbedTencentSender("LimitExceeded.PhoneNumberDailyLimit") + with pytest.raises(AppError) as excinfo: + await sender.send_code(PHONE, MASKED, "123456") + assert excinfo.value.code == ErrorCode.SMS_PROVIDER_FAILED + + +async def test_tencent_sender_network_error_maps_61000() -> None: + with pytest.raises(AppError) as excinfo: + await _ExplodingTencentSender().send_code(PHONE, MASKED, "123456") + assert excinfo.value.code == ErrorCode.SMS_PROVIDER_FAILED