Files
DadaoTask/backend/tests/conftest.py
T
Steven 7558637567
CI / backend (push) Canceled after 0s
feat(auth): 短信登录默认关,换成手机号/邮箱 + 密码,发信邮箱进后台
用户要求:内测的固定验证码 123456 等于"知道手机号就能进任何账号",先关掉;
登录改用账号密码,并多一种"邮箱当账号名"的方式;发信邮箱要能在后台改。

短信那条路(默认关):
- `SMS_LOGIN_ENABLED=false`,`/auth/sms/send` 与 `/auth/login` 统一回 2017,不是静默失败;
- 生产校验改成"开着短信登录就必须配真通道",不开时 provider 是 dev 也不拦;
- 新增 `GET /auth/methods`,登录页据此渲染,关着的路不出现(少一次"点进去才发现不能用")。

邮箱登录:
- `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引),登录与绑定都归一成小写;
- `POST /auth/login/password` 收 `phone` 或 `email`(二选一),失败统一 2013、连错 10 次锁 15 分钟;
- 绑定在 `PATCH /auth/me`:格式错 2018、被占用 2019、留空串表示解绑。

系统设置(跨租户,只有平台管理员能改):
- 新表 `system_setting`(不挂租户、无 RLS,写权限由 `deps.platform_admin` 把关);
- `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,授权码只写不读,留空表示沿用旧值;
- `services/mail.py` 只用标准库 smtplib:465 走 SSL、587 走 STARTTLS,认证/拒收/网络失败都归 2020 但消息不同;
- Web 新增「系统设置」页(状态卡 + 测试发信),侧栏只对平台管理员显示入口;账号页补「邮箱」。

验证:pytest 130 passed、ruff check/format 全绿、tsc 通过、vite build 通过;
`web/e2e/login-email-smtp.mjs` 15 项全绿(含用 126 授权码真发一封测试邮件)。

顺带记下的两个坑:`platform_admin_phones` 是 list,pydantic-settings 会按 JSON 解析,
要用 `NoDecode` 交给 before-validator 拆逗号;迁移号写成 0009 会撞既有的 0009,一并改成 0010。
2026-11-03 00:00:11 +08:00

119 lines
4.2 KiB
Python

"""测试夹具。
约定:
- 一律走真实 HTTP 栈(ASGITransport),不做 handler 级 mock —— 中间件/依赖/RLS 都是被测对象。
- 每个用例前清库,避免串味;清库走**迁移角色**(app 角色没有 TRUNCATE 权限,这是设计使然)。
"""
from __future__ import annotations
import os
os.environ["ENV"] = "test"
os.environ.setdefault("SMS_DEV_CODE", "123456")
# 测试里关掉发码节流,否则"同一手机号再登录一次"要等 60 秒
os.environ.setdefault("SMS_THROTTLE_SECONDS", "0")
# 线上默认关掉了短信验证码登录(固定码等于谁都能进),但测试夹具 `login()` 靠它批量造用户。
# 这里显式打开;想验证"关掉之后回 2017"的用例自己 monkeypatch 一下 settings 即可。
os.environ.setdefault("SMS_LOGIN_ENABLED", "true")
# 测试库由 config 的 test_database_url / test_migration_database_url 决定(见 backend/.env),
# 这里不再改 os.environ —— 配置对象自己会读 .env。
import uuid
from collections.abc import AsyncIterator
from typing import Any
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
from alembic import command
from alembic.config import Config
from app.config import get_settings
from app.db import engine
from app.main import app
from app.stores import reset_store_for_tests
settings = get_settings()
DEV_CODE = settings.sms_dev_code
_TABLES = "system_setting, audit_log, invitation, member, workspace, app_user"
def _run_migrations() -> None:
cfg = Config(str(os.path.join(os.path.dirname(__file__), "..", "alembic.ini")))
command.upgrade(cfg, "head")
@pytest.fixture(scope="session", autouse=True)
def migrated_database() -> None:
_run_migrations()
@pytest.fixture(autouse=True)
async def clean_database(migrated_database: None) -> AsyncIterator[None]:
owner_engine = create_async_engine(
settings.resolved_migration_database_url(), poolclass=NullPool
)
async with owner_engine.begin() as conn:
# 上一次跑挂掉(Ctrl-C、进程被杀)会留下 idle in transaction 的连接,
# 它握着行锁,TRUNCATE 就会一直等——不是测试慢,是被锁住了。
# 测试库是独占的,直接踢掉除自己以外的会话,再给个 lock_timeout 兜底。
await conn.execute(
text(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
"WHERE datname = current_database() "
"AND pid <> pg_backend_pid() AND state <> 'idle'"
)
)
await conn.execute(text("SET lock_timeout = '15s'"))
await conn.execute(text(f"TRUNCATE {_TABLES} CASCADE"))
await owner_engine.dispose()
reset_store_for_tests()
yield
@pytest.fixture(autouse=True)
async def dispose_app_engine() -> AsyncIterator[None]:
yield
await engine.dispose()
@pytest.fixture
async def client() -> AsyncIterator[AsyncClient]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
def auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def unwrap(response: Any) -> Any:
body = response.json()
assert body.get("code") == 0, f"期望成功,实际 {body}"
return body["data"]
async def login(client: AsyncClient, phone: str | None = None) -> dict[str, Any]:
"""发码 + 登录,返回 {token, refresh_token, user_id, phone}。"""
phone = phone or f"138{uuid.uuid4().int % 100_000_000:08d}"
await client.post("/api/v1/auth/sms/send", json={"phone": phone})
data = unwrap(await client.post("/api/v1/auth/login", json={"phone": phone, "code": DEV_CODE}))
return {
"token": data["tokens"]["access_token"],
"refresh_token": data["tokens"]["refresh_token"],
"user_id": data["user"]["id"],
"phone": phone,
"headers": auth(data["tokens"]["access_token"]),
}
async def create_workspace(client: AsyncClient, user: dict[str, Any], name: str) -> dict[str, Any]:
return unwrap(
await client.post("/api/v1/workspaces", json={"name": name}, headers=user["headers"])
)