fix(m0): 迁移可执行 + 测试全绿(33 条)
CI / backend (push) Canceled after 0s

跑通迁移与测试后修掉的真问题:

- alembic 0001:asyncpg 走预备语句,一次 execute 只能一条 SQL。
  CREATE FUNCTION / CREATE POLICY / GRANT 全部拆成单语句逐条执行,
  否则报 "cannot insert multiple commands into a prepared statement"。
- 创建工作区:ORM flush 只按 relationship 排序,裸外键不算数,
  member 可能先于 workspace 落地撞 FK。改为先 flush workspace 再插 member。
- datetime.UTC 是 3.11+ 才有的,本机 3.10 直接 ImportError。改用 timezone.utc。
- 幂等中间件:原来会缓存 4xx。校验失败被缓存后,用户改对参数仍拿到同一个 422。
  改为只缓存 <400。
- 发码节流窗口改为可配置 SMS_THROTTLE_SECONDS(测试置 0),
  节流用例自己显式打开,避免"整库关掉节流"导致节流从未被验证。
- 租户隔离用例:pytest.raises(Exception) 改为精确的 ProgrammingError,
  否则任何异常都会让"应用角色不能建表"这条断言假通过。

验收:pytest 33 passed;ruff check / format 通过;真机跑 uvicorn,
/health、/version、登录、建工作区、列表、/auth/me 全通。
This commit is contained in:
Steven
2026-10-28 03:22:59 +08:00
parent 0c53aa7062
commit 2b493bd9f8
14 changed files with 160 additions and 136 deletions
+4
View File
@@ -35,3 +35,7 @@ android/local.properties
pip-unpack-*/ pip-unpack-*/
pip-req-build-*/ pip-req-build-*/
tmprfx* tmprfx*
# 本机开发用的便携 PostgreSQL / 临时目录(不入库)
.local/
+1 -1
View File
@@ -5,11 +5,11 @@ from __future__ import annotations
import asyncio import asyncio
from logging.config import fileConfig from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool from sqlalchemy import pool
from sqlalchemy.engine import Connection from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.config import get_settings from app.config import get_settings
from app.models import Base from app.models import Base
+55 -35
View File
@@ -19,57 +19,76 @@ depends_on: str | Sequence[str] | None = None
APP_ROLE = "dadao_app" APP_ROLE = "dadao_app"
HELPER_SQL = """ # 说明:asyncpg 走"预备语句"执行,**一条 execute 里只能有一条 SQL**,
CREATE OR REPLACE FUNCTION app_ws_id() RETURNS uuid # 多条语句会报 `cannot insert multiple commands into a prepared statement`。
LANGUAGE sql STABLE AS $$ # 所以这里按语句拆成元组,逐条执行。
HELPER_STATEMENTS: tuple[str, ...] = (
"""
CREATE OR REPLACE FUNCTION app_ws_id() RETURNS uuid
LANGUAGE sql STABLE AS $$
SELECT nullif(current_setting('app.ws_id', true), '')::uuid SELECT nullif(current_setting('app.ws_id', true), '')::uuid
$$; $$
""",
CREATE OR REPLACE FUNCTION app_user_id() RETURNS uuid """
LANGUAGE sql STABLE AS $$ CREATE OR REPLACE FUNCTION app_user_id() RETURNS uuid
LANGUAGE sql STABLE AS $$
SELECT nullif(current_setting('app.user_id', true), '')::uuid SELECT nullif(current_setting('app.user_id', true), '')::uuid
$$; $$
""" """,
)
POLICY_SQL = f""" POLICY_STATEMENTS: tuple[str, ...] = (
-- ── workspace:本租户 OR 我是其成员(用于"我加入了哪些工作区")── # ── workspace:本租户 OR 我是其成员(用于"我加入了哪些工作区")──
ALTER TABLE workspace ENABLE ROW LEVEL SECURITY; "ALTER TABLE workspace ENABLE ROW LEVEL SECURITY;",
CREATE POLICY workspace_isolation ON workspace """
CREATE POLICY workspace_isolation ON workspace
USING ( USING (
id = app_ws_id() id = app_ws_id()
OR id IN (SELECT m.workspace_id FROM member m WHERE m.user_id = app_user_id()) OR id IN (SELECT m.workspace_id FROM member m WHERE m.user_id = app_user_id())
) )
WITH CHECK (id = app_ws_id()); WITH CHECK (id = app_ws_id());
""",
-- ── member:本租户成员 OR 我自己那条 ── # ── member:本租户成员 OR 我自己那条 ──
ALTER TABLE member ENABLE ROW LEVEL SECURITY; "ALTER TABLE member ENABLE ROW LEVEL SECURITY;",
CREATE POLICY member_isolation ON member """
CREATE POLICY member_isolation ON member
USING (workspace_id = app_ws_id() OR user_id = app_user_id()) USING (workspace_id = app_ws_id() OR user_id = app_user_id())
WITH CHECK (workspace_id = app_ws_id()); WITH CHECK (workspace_id = app_ws_id());
""",
-- ── invitation ── # ── invitation ──
ALTER TABLE invitation ENABLE ROW LEVEL SECURITY; "ALTER TABLE invitation ENABLE ROW LEVEL SECURITY;",
CREATE POLICY invitation_isolation ON invitation """
CREATE POLICY invitation_isolation ON invitation
USING (workspace_id = app_ws_id()) USING (workspace_id = app_ws_id())
WITH CHECK (workspace_id = app_ws_id()); WITH CHECK (workspace_id = app_ws_id());
""",
-- ── audit_log:只增不改,租户内可见 ── # ── audit_log:只增不改,租户内可见 ──
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY; "ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;",
CREATE POLICY audit_log_isolation ON audit_log """
CREATE POLICY audit_log_isolation ON audit_log
USING (workspace_id = app_ws_id()) USING (workspace_id = app_ws_id())
WITH CHECK (workspace_id = app_ws_id()); WITH CHECK (workspace_id = app_ws_id());
""",
# ── 授权:应用角色没有 DDL、没有 BYPASSRLS,只能读写到自己该看的数据 ──
f"GRANT USAGE ON SCHEMA public TO {APP_ROLE};",
(
"GRANT SELECT, INSERT, UPDATE, DELETE ON "
f"workspace, app_user, member, invitation, audit_log TO {APP_ROLE};"
),
(
"ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public "
f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE};"
),
)
-- ── 授权:应用角色没有 DDL、没有 BYPASSRLS,只能读写到自己该看的数据 ──
GRANT USAGE ON SCHEMA public TO {APP_ROLE}; def _exec_each(statements: tuple[str, ...]) -> None:
GRANT SELECT, INSERT, UPDATE, DELETE ON for statement in statements:
workspace, app_user, member, invitation, audit_log TO {APP_ROLE}; op.execute(statement)
ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE};
"""
def upgrade() -> None: def upgrade() -> None:
op.execute(HELPER_SQL) _exec_each(HELPER_STATEMENTS)
op.create_table( op.create_table(
"app_user", "app_user",
@@ -184,7 +203,7 @@ def upgrade() -> None:
) )
op.create_index("ix_audit_ws_created", "audit_log", ["workspace_id", "created_at"]) op.create_index("ix_audit_ws_created", "audit_log", ["workspace_id", "created_at"])
op.execute(POLICY_SQL) _exec_each(POLICY_STATEMENTS)
def downgrade() -> None: def downgrade() -> None:
@@ -194,4 +213,5 @@ def downgrade() -> None:
op.drop_table("member") op.drop_table("member")
op.drop_table("workspace") op.drop_table("workspace")
op.drop_table("app_user") op.drop_table("app_user")
op.execute("DROP FUNCTION IF EXISTS app_ws_id(); DROP FUNCTION IF EXISTS app_user_id();") op.execute("DROP FUNCTION IF EXISTS app_ws_id();")
op.execute("DROP FUNCTION IF EXISTS app_user_id();")
+7 -3
View File
@@ -13,7 +13,7 @@ from ..config import get_settings
from ..deps import CurrentUser, SessionDep from ..deps import CurrentUser, SessionDep
from ..errors import AppError, ErrorCode from ..errors import AppError, ErrorCode
from ..models import AppUser, Member, Workspace from ..models import AppUser, Member, Workspace
from ..schemas import Envelope, ok from ..schemas import ok
from ..security import create_token_pair, decode_token from ..security import create_token_pair, decode_token
from ..stores import get_store, sms_code_key, sms_throttle_key from ..stores import get_store, sms_code_key, sms_throttle_key
@@ -43,9 +43,13 @@ class RefreshIn(BaseModel):
@router.post("/sms/send", summary="发送登录验证码") @router.post("/sms/send", summary="发送登录验证码")
async def send_code(body: SendCodeIn) -> dict: async def send_code(body: SendCodeIn) -> dict:
store = get_store() store = get_store()
hits = await store.incr(sms_throttle_key(body.phone), 60) if settings.sms_throttle_seconds > 0:
hits = await store.incr(sms_throttle_key(body.phone), settings.sms_throttle_seconds)
if hits > 1: if hits > 1:
raise AppError(ErrorCode.SMS_TOO_FREQUENT, "发送过于频繁,请 60 秒后重试") raise AppError(
ErrorCode.SMS_TOO_FREQUENT,
f"发送过于频繁,请 {settings.sms_throttle_seconds} 秒后重试",
)
if settings.sms_provider == "dev": if settings.sms_provider == "dev":
code = settings.sms_dev_code code = settings.sms_dev_code
+1 -1
View File
@@ -20,7 +20,7 @@ class UserOut(BaseModel):
class TokenOut(BaseModel): class TokenOut(BaseModel):
access_token: str access_token: str
refresh_token: str refresh_token: str
token_type: str = "bearer" token_type: str = "bearer" # noqa: S105 - 不是密码,是 OAuth2 的固定字段值
expires_in: int expires_in: int
+11 -14
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Query, Request from fastapi import APIRouter, Query, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -14,7 +14,7 @@ from ..db import bind_tenant
from ..deps import AdminDep, CurrentUser, MemberDep, SessionDep, TenantDep from ..deps import AdminDep, CurrentUser, MemberDep, SessionDep, TenantDep
from ..errors import AppError, ErrorCode from ..errors import AppError, ErrorCode
from ..models import AppUser, Invitation, Member, Workspace from ..models import AppUser, Invitation, Member, Workspace
from ..schemas import Page, decode_cursor, encode_cursor, ok from ..schemas import decode_cursor, encode_cursor, ok
from ..security import invite_code_matches, new_invite_code from ..security import invite_code_matches, new_invite_code
router = APIRouter(prefix="/workspaces", tags=["工作区"]) router = APIRouter(prefix="/workspaces", tags=["工作区"])
@@ -66,9 +66,10 @@ async def create_workspace(
ws = Workspace(id=ws_id, name=body.name, slug=slug, owner_id=user.id, plan="free") ws = Workspace(id=ws_id, name=body.name, slug=slug, owner_id=user.id, plan="free")
session.add(ws) session.add(ws)
session.add( # 先刷 workspace 再插 member:ORM 的 flush 顺序只按 relationship 排序,
Member(workspace_id=ws_id, user_id=user.id, role="owner", status="active") # 没有 relationship 的裸外键不会被考虑,member 可能先于 workspace 落地而撞 FK。
) await session.flush()
session.add(Member(workspace_id=ws_id, user_id=user.id, role="owner", status="active"))
await session.flush() await session.flush()
await audit( await audit(
session, session,
@@ -156,7 +157,7 @@ async def create_invitation(
code=new_invite_code(), code=new_invite_code(),
role=body.role, role=body.role,
invited_by=ctx.user.id, invited_by=ctx.user.id,
expires_at=datetime.now(UTC) + timedelta(hours=body.expires_hours), expires_at=datetime.now(timezone.utc) + timedelta(hours=body.expires_hours),
) )
session.add(invitation) session.add(invitation)
await session.flush() await session.flush()
@@ -192,19 +193,15 @@ async def join_workspace(
) )
) )
).scalars() ).scalars()
matched = next( matched = next((inv for inv in invitation if invite_code_matches(body.code, inv.code)), None)
(inv for inv in invitation if invite_code_matches(body.code, inv.code)), None
)
if matched is None: if matched is None:
raise AppError(ErrorCode.INVITATION_INVALID, "邀请码无效或已使用") raise AppError(ErrorCode.INVITATION_INVALID, "邀请码无效或已使用")
if matched.expires_at < datetime.now(UTC): if matched.expires_at < datetime.now(timezone.utc):
raise AppError(ErrorCode.INVITATION_INVALID, "邀请码已过期") raise AppError(ErrorCode.INVITATION_INVALID, "邀请码已过期")
existing = ( existing = (
await session.execute( await session.execute(
select(Member).where( select(Member).where(Member.workspace_id == ctx.ws_id, Member.user_id == ctx.user.id)
Member.workspace_id == ctx.ws_id, Member.user_id == ctx.user.id
)
) )
).scalar_one_or_none() ).scalar_one_or_none()
if existing is not None and existing.status == "active": if existing is not None and existing.status == "active":
@@ -223,7 +220,7 @@ async def join_workspace(
existing.role = matched.role existing.role = matched.role
existing.status = "active" existing.status = "active"
matched.accepted_at = datetime.now(UTC) matched.accepted_at = datetime.now(timezone.utc)
matched.accepted_by = ctx.user.id matched.accepted_by = ctx.user.id
await session.flush() await session.flush()
await audit( await audit(
+4 -4
View File
@@ -9,7 +9,7 @@ from typing import Literal
from pydantic import Field, field_validator from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
DEV_JWT_SECRET = "dev-only-change-me" DEV_JWT_SECRET = "dev-only-change-me" # noqa: S105 - 仅开发默认值,生产启动时会被校验拦下
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -27,9 +27,7 @@ class Settings(BaseSettings):
# ── 数据库 ──────────────────────────────────────────── # ── 数据库 ────────────────────────────────────────────
# 应用角色:受 RLS 约束,非 owner、非 superuser # 应用角色:受 RLS 约束,非 owner、非 superuser
database_url: str = ( database_url: str = "postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5433/dadaotask"
"postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5433/dadaotask"
)
# 迁移角色:owner,可绕过 RLS,仅 alembic / 运维脚本使用 # 迁移角色:owner,可绕过 RLS,仅 alembic / 运维脚本使用
migration_database_url: str = ( migration_database_url: str = (
"postgresql+asyncpg://dadao_owner:dadao_dev_pw@127.0.0.1:5433/dadaotask" "postgresql+asyncpg://dadao_owner:dadao_dev_pw@127.0.0.1:5433/dadaotask"
@@ -46,6 +44,8 @@ class Settings(BaseSettings):
access_token_minutes: int = 30 access_token_minutes: int = 30
refresh_token_days: int = 14 refresh_token_days: int = 14
sms_code_ttl_seconds: int = 300 sms_code_ttl_seconds: int = 300
# 同一手机号两次发码的最小间隔;测试里设为 0 可关闭节流
sms_throttle_seconds: int = 60
sms_dev_code: str = "123456" sms_dev_code: str = "123456"
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev" sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
+7 -6
View File
@@ -39,16 +39,13 @@ SessionFactory = async_sessionmaker(engine, expire_on_commit=False, autoflush=Fa
async def get_session() -> AsyncIterator[AsyncSession]: async def get_session() -> AsyncIterator[AsyncSession]:
"""一个请求 = 一个事务。异常即整体回滚。""" """一个请求 = 一个事务。异常即整体回滚。"""
async with SessionFactory() as session: async with SessionFactory() as session, session.begin():
async with session.begin():
yield session yield session
async def bind_tenant(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID | None) -> None: async def bind_tenant(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID | None) -> None:
"""把租户上下文写进当前事务。必须在任何业务查询之前调用。""" """把租户上下文写进当前事务。必须在任何业务查询之前调用。"""
await session.execute( await session.execute(text("SELECT set_config('app.ws_id', :ws, true)"), {"ws": str(ws_id)})
text("SELECT set_config('app.ws_id', :ws, true)"), {"ws": str(ws_id)}
)
if user_id is not None: if user_id is not None:
await session.execute( await session.execute(
text("SELECT set_config('app.user_id', :uid, true)"), {"uid": str(user_id)} text("SELECT set_config('app.user_id', :uid, true)"), {"uid": str(user_id)}
@@ -65,6 +62,7 @@ async def bind_user(session: AsyncSession, user_id: uuid.UUID) -> None:
async def current_app_role(session: AsyncSession) -> dict[str, Any]: async def current_app_role(session: AsyncSession) -> dict[str, Any]:
"""自检用:确认应用连接的不是特权角色。""" """自检用:确认应用连接的不是特权角色。"""
row = ( row = (
(
await session.execute( await session.execute(
text( text(
"SELECT current_user AS role, " "SELECT current_user AS role, "
@@ -73,7 +71,10 @@ async def current_app_role(session: AsyncSession) -> dict[str, Any]:
"(SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user) AS bypass_rls" "(SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user) AS bypass_rls"
) )
) )
).mappings().one() )
.mappings()
.one()
)
return dict(row) return dict(row)
+8 -4
View File
@@ -10,7 +10,7 @@ import json
import logging import logging
import sys import sys
from contextvars import ContextVar from contextvars import ContextVar
from datetime import UTC, datetime from datetime import datetime, timezone
from typing import Any from typing import Any
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-") request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
@@ -37,10 +37,14 @@ def _mask(value: Any, depth: int = 0) -> Any:
return "<deep>" return "<deep>"
if isinstance(value, dict): if isinstance(value, dict):
return { return {
key: (MASK if any(h in str(key).lower() for h in _SENSITIVE_HINTS) else _mask(val, depth + 1)) key: (
MASK
if any(hint in str(key).lower() for hint in _SENSITIVE_HINTS)
else _mask(val, depth + 1)
)
for key, val in value.items() for key, val in value.items()
} }
if isinstance(value, (list, tuple)): if isinstance(value, list | tuple):
return [_mask(item, depth + 1) for item in value] return [_mask(item, depth + 1) for item in value]
return value return value
@@ -48,7 +52,7 @@ def _mask(value: Any, depth: int = 0) -> Any:
class JsonFormatter(logging.Formatter): class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str: def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = { payload: dict[str, Any] = {
"ts": datetime.now(UTC).isoformat(timespec="milliseconds"), "ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
"level": record.levelname, "level": record.levelname,
"logger": record.name, "logger": record.name,
"msg": record.getMessage(), "msg": record.getMessage(),
+4 -2
View File
@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any from typing import Any
@@ -28,7 +27,10 @@ logger = get_logger("app")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
settings.validate_runtime() settings.validate_runtime()
logger.info("服务启动", extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}}) logger.info(
"服务启动",
extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}},
)
yield yield
await engine.dispose() await engine.dispose()
+3 -1
View File
@@ -97,7 +97,9 @@ class IdempotencyMiddleware(BaseHTTPMiddleware):
) )
response = await call_next(request) response = await call_next(request)
if response.status_code >= 500: # 只缓存成功响应:4xx 往往是"这次请求本身有问题"(校验失败、权限不足),
# 缓存住会让用户改对参数后仍然拿到同一个错误。
if response.status_code >= 400:
return response return response
body = b"" body = b""
+8 -6
View File
@@ -11,19 +11,21 @@ import os
os.environ["ENV"] = "test" os.environ["ENV"] = "test"
os.environ.setdefault("SMS_DEV_CODE", "123456") os.environ.setdefault("SMS_DEV_CODE", "123456")
# 测试里关掉发码节流,否则"同一手机号再登录一次"要等 60 秒
os.environ.setdefault("SMS_THROTTLE_SECONDS", "0")
import uuid import uuid
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import Any from typing import Any
import pytest import pytest
from alembic import command
from alembic.config import Config
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
from alembic import command
from alembic.config import Config
from app.config import get_settings from app.config import get_settings
from app.db import engine from app.db import engine
from app.main import app from app.main import app
@@ -82,9 +84,7 @@ async def login(client: AsyncClient, phone: str | None = None) -> dict[str, Any]
"""发码 + 登录,返回 {token, refresh_token, user_id, phone}。""" """发码 + 登录,返回 {token, refresh_token, user_id, phone}。"""
phone = phone or f"138{uuid.uuid4().int % 100_000_000:08d}" phone = phone or f"138{uuid.uuid4().int % 100_000_000:08d}"
await client.post("/api/v1/auth/sms/send", json={"phone": phone}) await client.post("/api/v1/auth/sms/send", json={"phone": phone})
data = unwrap( data = unwrap(await client.post("/api/v1/auth/login", json={"phone": phone, "code": DEV_CODE}))
await client.post("/api/v1/auth/login", json={"phone": phone, "code": DEV_CODE})
)
return { return {
"token": data["tokens"]["access_token"], "token": data["tokens"]["access_token"],
"refresh_token": data["tokens"]["refresh_token"], "refresh_token": data["tokens"]["refresh_token"],
@@ -95,4 +95,6 @@ async def login(client: AsyncClient, phone: str | None = None) -> dict[str, Any]
async def create_workspace(client: AsyncClient, user: dict[str, Any], name: str) -> dict[str, Any]: 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"])) return unwrap(
await client.post("/api/v1/workspaces", json={"name": name}, headers=user["headers"])
)
+11 -16
View File
@@ -22,26 +22,25 @@ async def test_login_is_idempotent_for_same_phone(client: AsyncClient) -> None:
async def test_wrong_code_rejected(client: AsyncClient) -> None: async def test_wrong_code_rejected(client: AsyncClient) -> None:
await client.post("/api/v1/auth/sms/send", json={"phone": "13800000003"}) await client.post("/api/v1/auth/sms/send", json={"phone": "13800000003"})
resp = await client.post( resp = await client.post("/api/v1/auth/login", json={"phone": "13800000003", "code": "000000"})
"/api/v1/auth/login", json={"phone": "13800000003", "code": "000000"}
)
assert resp.status_code == 400 assert resp.status_code == 400
assert resp.json()["code"] == 2010 assert resp.json()["code"] == 2010
async def test_code_can_only_be_used_once(client: AsyncClient) -> None: async def test_code_can_only_be_used_once(client: AsyncClient) -> None:
await client.post("/api/v1/auth/sms/send", json={"phone": "13800000004"}) await client.post("/api/v1/auth/sms/send", json={"phone": "13800000004"})
ok = await client.post( ok = await client.post("/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE})
"/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE}
)
assert ok.status_code == 200 assert ok.status_code == 200
again = await client.post( again = await client.post("/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE})
"/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE}
)
assert again.json()["code"] == 2010 assert again.json()["code"] == 2010
async def test_sms_throttled(client: AsyncClient) -> None: async def test_sms_throttled(client: AsyncClient, monkeypatch) -> None:
# 全局测试默认关掉了节流(SMS_THROTTLE_SECONDS=0),这里显式打开
from app.config import get_settings
monkeypatch.setattr(get_settings(), "sms_throttle_seconds", 60)
payload = {"phone": "13800000005"} payload = {"phone": "13800000005"}
assert (await client.post("/api/v1/auth/sms/send", json=payload)).status_code == 200 assert (await client.post("/api/v1/auth/sms/send", json=payload)).status_code == 200
second = await client.post("/api/v1/auth/sms/send", json=payload) second = await client.post("/api/v1/auth/sms/send", json=payload)
@@ -57,9 +56,7 @@ async def test_refresh_token_cannot_be_used_as_access_token(client: AsyncClient)
async def test_access_token_cannot_be_used_to_refresh(client: AsyncClient) -> None: async def test_access_token_cannot_be_used_to_refresh(client: AsyncClient) -> None:
user = await login(client) user = await login(client)
resp = await client.post( resp = await client.post("/api/v1/auth/refresh", json={"refresh_token": user["token"]})
"/api/v1/auth/refresh", json={"refresh_token": user["token"]}
)
assert resp.status_code == 401 assert resp.status_code == 401
assert resp.json()["code"] == 2003 assert resp.json()["code"] == 2003
@@ -67,9 +64,7 @@ async def test_access_token_cannot_be_used_to_refresh(client: AsyncClient) -> No
async def test_refresh_returns_working_token_pair(client: AsyncClient) -> None: async def test_refresh_returns_working_token_pair(client: AsyncClient) -> None:
user = await login(client) user = await login(client)
data = unwrap( data = unwrap(
await client.post( await client.post("/api/v1/auth/refresh", json={"refresh_token": user["refresh_token"]})
"/api/v1/auth/refresh", json={"refresh_token": user["refresh_token"]}
)
) )
me = await client.get("/api/v1/auth/me", headers=auth(data["access_token"])) me = await client.get("/api/v1/auth/me", headers=auth(data["access_token"]))
assert me.status_code == 200 assert me.status_code == 200
+11 -18
View File
@@ -13,6 +13,7 @@ import uuid
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.exc import ProgrammingError
from app.db import SessionFactory, bind_tenant, current_app_role from app.db import SessionFactory, bind_tenant, current_app_role
from app.models import TENANT_TABLES from app.models import TENANT_TABLES
@@ -37,9 +38,7 @@ async def test_cannot_read_other_workspace(client: AsyncClient) -> None:
async def test_cannot_list_other_workspace_members(client: AsyncClient) -> None: async def test_cannot_list_other_workspace_members(client: AsyncClient) -> None:
alice, _bob, _ws_a, ws_b = await _two_tenants(client) alice, _bob, _ws_a, ws_b = await _two_tenants(client)
resp = await client.get( resp = await client.get(f"/api/v1/workspaces/{ws_b['id']}/members", headers=alice["headers"])
f"/api/v1/workspaces/{ws_b['id']}/members", headers=alice["headers"]
)
assert resp.status_code == 403 assert resp.status_code == 403
@@ -66,15 +65,12 @@ async def test_rls_hides_rows_at_database_level(client: AsyncClient) -> None:
ws_a_id = uuid.UUID(ws_a["id"]) ws_a_id = uuid.UUID(ws_a["id"])
ws_b_id = uuid.UUID(ws_b["id"]) ws_b_id = uuid.UUID(ws_b["id"])
async with SessionFactory() as session: async with SessionFactory() as session, session.begin():
async with session.begin():
await bind_tenant(session, ws_a_id, None) await bind_tenant(session, ws_a_id, None)
visible = ( visible = (await session.execute(text("SELECT id FROM workspace"))).scalars().all()
await session.execute(text("SELECT id FROM workspace"))
).scalars().all()
member_ids = ( member_ids = (
await session.execute(text("SELECT workspace_id FROM member")) (await session.execute(text("SELECT workspace_id FROM member"))).scalars().all()
).scalars().all() )
assert [str(v) for v in visible] == [ws_a["id"]] assert [str(v) for v in visible] == [ws_a["id"]]
assert set(member_ids) == {ws_a_id} assert set(member_ids) == {ws_a_id}
@@ -83,8 +79,7 @@ async def test_rls_hides_rows_at_database_level(client: AsyncClient) -> None:
async def test_rls_without_context_returns_nothing(client: AsyncClient) -> None: async def test_rls_without_context_returns_nothing(client: AsyncClient) -> None:
await _two_tenants(client) await _two_tenants(client)
async with SessionFactory() as session: async with SessionFactory() as session, session.begin():
async with session.begin():
count = (await session.execute(text("SELECT count(*) FROM workspace"))).scalar_one() count = (await session.execute(text("SELECT count(*) FROM workspace"))).scalar_one()
assert count == 0, "未绑定租户上下文时不应看到任何行" assert count == 0, "未绑定租户上下文时不应看到任何行"
@@ -100,14 +95,13 @@ async def test_rls_enabled_on_every_tenant_table(client: AsyncClient) -> None:
{"names": list(TENANT_TABLES)}, {"names": list(TENANT_TABLES)},
) )
).all() ).all()
status = {name: enabled for name, enabled in rows} status = dict(rows)
assert set(status) == set(TENANT_TABLES), "有租户表没建出来" assert set(status) == set(TENANT_TABLES), "有租户表没建出来"
assert all(status.values()), f"未启用 RLS 的表:{[k for k, v in status.items() if not v]}" assert all(status.values()), f"未启用 RLS 的表:{[k for k, v in status.items() if not v]}"
async def test_app_role_cannot_bypass_rls(client: AsyncClient) -> None: async def test_app_role_cannot_bypass_rls(client: AsyncClient) -> None:
async with SessionFactory() as session: async with SessionFactory() as session, session.begin():
async with session.begin():
role = await current_app_role(session) role = await current_app_role(session)
assert role["is_super"] is False, "应用角色绝不能是 superuser,否则 RLS 被跳过" assert role["is_super"] is False, "应用角色绝不能是 superuser,否则 RLS 被跳过"
assert role["bypass_rls"] is False, "应用角色不能带 BYPASSRLS" assert role["bypass_rls"] is False, "应用角色不能带 BYPASSRLS"
@@ -116,7 +110,6 @@ async def test_app_role_cannot_bypass_rls(client: AsyncClient) -> None:
async def test_app_role_has_no_ddl(client: AsyncClient) -> None: async def test_app_role_has_no_ddl(client: AsyncClient) -> None:
"""应用角色不能建表——否则一次 SQL 注入就能改结构。""" """应用角色不能建表——否则一次 SQL 注入就能改结构。"""
async with SessionFactory() as session: async with SessionFactory() as session, session.begin():
async with session.begin(): with pytest.raises(ProgrammingError): # 权限不足,DDL 必须失败
with pytest.raises(Exception):
await session.execute(text("CREATE TABLE should_not_exist (id int)")) await session.execute(text("CREATE TABLE should_not_exist (id int)"))