"""邮箱注册:发码 → 验码 → 建账号 → 直接登录。 手机验证码关掉以后这是唯一的新用户入口,所以三条都要真的走通: ① 发码真的走到 SMTP(不是"调了函数就算");② 验证码错了/过期了要报错; ③ 建出来的账号没有手机号也能正常用(`app_user.phone` 因此可空)。 本机联调用的 `EMAIL_CODE_DEV_ECHO` 只在 dev 生效,生产打开会被 validate_runtime 拒掉。 """ from __future__ import annotations from typing import Any import pytest from httpx import AsyncClient from app.api import auth as auth_api from app.errors import ErrorCode from app.services import mail from tests.conftest import unwrap def _settings() -> Any: """settings 是模块级的缓存对象,monkeypatch 改它、用例结束自动还原。""" return auth_api.settings @pytest.fixture async def sent(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: """把真发信换成记账:记下收件人与验证码,不碰网络。""" box: list[dict[str, Any]] = [] async def fake_send(session: Any, *, to: str, code: str, minutes: int) -> None: box.append({"to": to, "code": code, "minutes": minutes}) monkeypatch.setattr(_settings(), "email_code_dev_echo", False) monkeypatch.setattr(_settings(), "email_code_throttle_seconds", 0) monkeypatch.setattr(mail, "send_verify_code", fake_send) return box async def _send(client: AsyncClient, email: str) -> dict[str, Any]: return unwrap(await client.post("/api/v1/auth/email/code", json={"email": email})) async def test_send_code_goes_through_smtp(client: AsyncClient, sent: list[dict[str, Any]]) -> None: """发码必须真的交给发信服务,并且邮箱统一小写。""" data = await _send(client, " Newcomer@Example.COM ") assert data["sent"] is True assert data["expires_in"] > 0 # 不带 dev_code:真发信时把码回给客户端等于白验 assert "dev_code" not in data assert sent == [{"to": "newcomer@example.com", "code": sent[0]["code"], "minutes": 10}] assert len(sent[0]["code"]) == 6 and sent[0]["code"].isdigit() async def test_dev_echo_returns_code_without_sending( client: AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: """本机联调:不回真信,把码回给调用方(生产会拒绝启动)。""" called: list[str] = [] async def fake_send(session: Any, *, to: str, code: str, minutes: int) -> None: called.append(to) monkeypatch.setattr(_settings(), "email_code_dev_echo", True) monkeypatch.setattr(_settings(), "email_code_throttle_seconds", 0) monkeypatch.setattr(mail, "send_verify_code", fake_send) data = await _send(client, "dev@example.com") assert data["sent"] is True assert len(data["dev_code"]) == 6 assert called == [] async def test_register_creates_account_and_logs_in( client: AsyncClient, sent: list[dict[str, Any]] ) -> None: """注册完就是登录态:能拿到令牌,/auth/me 也认。""" email = "fresh@example.com" await _send(client, email) code = sent[-1]["code"] data = unwrap( await client.post( "/api/v1/auth/register", json={"email": " FRESH@example.com ", "code": code, "password": "dadao2026"}, ) ) assert data["user"]["email"] == email assert data["user"]["email_verified"] is True assert data["user"]["has_password"] is True # 邮箱注册没有手机号:脱敏字段留空,别拼出一串星号 assert data["user"]["phone"] == "" # 没填昵称就取 @ 前面那段,别让新账号顶着一个空名字 assert data["user"]["nickname"] == "fresh" headers = {"Authorization": f"Bearer {data['tokens']['access_token']}"} me = unwrap(await client.get("/api/v1/auth/me", headers=headers)) assert me["user"]["id"] == data["user"]["id"] # 验证码用过就作废,不能拿同一个码再注册一次 replay = await client.post( "/api/v1/auth/register", json={"email": email, "code": code, "password": "dadao2026"}, ) assert replay.json()["code"] == ErrorCode.EMAIL_CODE_INVALID # 注册完可以用「邮箱 + 密码」登录 login = unwrap( await client.post( "/api/v1/auth/login/password", json={"email": email, "password": "dadao2026"}, ) ) assert login["user"]["id"] == data["user"]["id"] async def test_register_rejects_wrong_or_missing_code( client: AsyncClient, sent: list[dict[str, Any]] ) -> None: email = "oops@example.com" await _send(client, email) wrong = await client.post( "/api/v1/auth/register", json={"email": email, "code": "000000", "password": "dadao2026"}, ) assert wrong.json()["code"] == ErrorCode.EMAIL_CODE_INVALID # 压根没发过码:同样"过期",不告诉对方"这个邮箱存不存在" never = await client.post( "/api/v1/auth/register", json={"email": "never@example.com", "code": "123456", "password": "dadao2026"}, ) assert never.json()["code"] == ErrorCode.EMAIL_CODE_INVALID async def test_register_code_burns_out_after_too_many_tries( client: AsyncClient, sent: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch ) -> None: """6 位数可以被刮,所以错够次数就把码作废。""" monkeypatch.setattr(_settings(), "email_code_max_attempts", 2) email = "guess@example.com" await _send(client, email) good = sent[-1]["code"] wrong = "000000" if good != "000000" else "111111" for _ in range(2): resp = await client.post( "/api/v1/auth/register", json={"email": email, "code": wrong, "password": "dadao2026"}, ) assert resp.json()["code"] == ErrorCode.EMAIL_CODE_INVALID burned = await client.post( "/api/v1/auth/register", json={"email": email, "code": good, "password": "dadao2026"}, ) assert burned.json()["code"] == ErrorCode.EMAIL_CODE_INVALID assert "重新获取" in burned.json()["message"] async def test_send_code_refuses_registered_email( client: AsyncClient, sent: list[dict[str, Any]] ) -> None: email = "taken@example.com" await _send(client, email) unwrap( await client.post( "/api/v1/auth/register", json={"email": email, "code": sent[-1]["code"], "password": "dadao2026"}, ) ) again = await client.post("/api/v1/auth/email/code", json={"email": email}) assert again.json()["code"] == ErrorCode.EMAIL_TAKEN async def test_send_code_throttles(client: AsyncClient, sent: list[dict[str, Any]]) -> None: _settings().email_code_throttle_seconds = 60 try: await _send(client, "throttle@example.com") again = await client.post("/api/v1/auth/email/code", json={"email": "throttle@example.com"}) assert again.json()["code"] == ErrorCode.EMAIL_CODE_TOO_FREQUENT finally: _settings().email_code_throttle_seconds = 0 async def test_register_validates_password_and_email( client: AsyncClient, sent: list[dict[str, Any]] ) -> None: await _send(client, "weak@example.com") weak = await client.post( "/api/v1/auth/register", json={"email": "weak@example.com", "code": sent[-1]["code"], "password": "short"}, ) assert weak.json()["code"] == ErrorCode.PASSWORD_WEAK bad = await client.post("/api/v1/auth/email/code", json={"email": "not-an-email"}) assert bad.json()["code"] == ErrorCode.EMAIL_INVALID async def test_registration_can_be_closed( client: AsyncClient, sent: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setattr(_settings(), "email_register_enabled", False) assert (await client.post("/api/v1/auth/email/code", json={"email": "x@example.com"})).json()[ "code" ] == ErrorCode.LOGIN_METHOD_DISABLED assert ( await client.post( "/api/v1/auth/register", json={"email": "x@example.com", "code": "123456", "password": "dadao2026"}, ) ).json()["code"] == ErrorCode.LOGIN_METHOD_DISABLED # /auth/methods 也要如实告诉前端这条路关着 methods = unwrap(await client.get("/api/v1/auth/methods")) assert methods["email_register"] is False