短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。
先发码再注册,不是"注册时顺带发信"
POST /auth/email/code 只做三件事:查重、限流、发码。
填完密码才被告知"这个邮箱注册过了"最气人,所以占用(2019)在发码前就回。
邮箱既是账号名也是唯一联系方式,所以必须真验一次码
验证通过时间落 app_user.email_verified_at,/auth/me 回 email_verified。
"随手填一个"会让账号变成没人能找回的孤岛。
app_user.phone 改为可空(迁移 0011)
邮箱注册的账号本来就没有手机号。user.phone 对这类账号回空串,
成员列表同理,不再伪装成一串星号。
防刷
同邮箱 60 秒一次(2022)、验证码 10 分钟有效、错 5 次即作废(2021)。
6 位数可以被刮,不设上限等于把注册口子交给脚本。
本机联调开关 EMAIL_CODE_DEV_ECHO
为 true 时把码回给调用方且不发真信——否则每跑一次 e2e 就往真实收件箱塞一封。
生产打开会让 validate_runtime() 拒绝启动。
前端
新增 /register(邮箱、验证码、密码、确认、昵称;60 秒倒计时;
服务端关着注册时整页只说"联系管理员开通"),登录页按 /auth/methods
决定要不要显示「还没有账号?用邮箱注册」。
验证
pytest 139 passed、ruff check / format 全绿
tsc --noEmit 0、vite build 通过
e2e register.mjs 7 项:登录页入口 → 拿码 → 注册落进首页 → 退出用邮箱+密码登回来
→ 同一邮箱再发码回 2019 → 控制台零报错
e2e login-email-smtp.mjs 15 项回归通过(含真发一封测试信)
e2e account.mjs 14 项、knowledge.mjs 全绿
顺带修的两处测试
test_account 的字段白名单补 email_verified(接口确实多了这个字段);
test_config_prod 用 Settings(_env_file=None, ...) 构造,别被本机 .env 里的
EMAIL_CODE_DEV_ECHO=true 带偏——那个开关在生产是硬错误,本机却是跑 e2e 必需的。
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
"""邮箱注册:发码 → 验码 → 建账号 → 直接登录。
|
||||
|
||||
手机验证码关掉以后这是唯一的新用户入口,所以三条都要真的走通:
|
||||
① 发码真的走到 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
|
||||
Reference in New Issue
Block a user