From 2b493bd9f828d239880380afe10be92e44bbcb79 Mon Sep 17 00:00:00 2001 From: Steven Date: Wed, 28 Oct 2026 03:22:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(m0):=20=E8=BF=81=E7=A7=BB=E5=8F=AF=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=20+=20=E6=B5=8B=E8=AF=95=E5=85=A8=E7=BB=BF=EF=BC=8833?= =?UTF-8?q?=20=E6=9D=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跑通迁移与测试后修掉的真问题: - 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 全通。 --- .gitignore | 4 + backend/alembic/env.py | 2 +- backend/alembic/versions/0001_init.py | 114 +++++++++++++++---------- backend/app/api/auth.py | 12 ++- backend/app/api/schemas.py | 2 +- backend/app/api/workspaces.py | 25 +++--- backend/app/config.py | 8 +- backend/app/db.py | 27 +++--- backend/app/logging_setup.py | 12 ++- backend/app/main.py | 6 +- backend/app/middleware.py | 4 +- backend/tests/conftest.py | 14 +-- backend/tests/test_auth.py | 27 +++--- backend/tests/test_tenant_isolation.py | 39 ++++----- 14 files changed, 160 insertions(+), 136 deletions(-) diff --git a/.gitignore b/.gitignore index 59d3f38..f1be24b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ android/local.properties pip-unpack-*/ pip-req-build-*/ tmprfx* + +# 本机开发用的便携 PostgreSQL / 临时目录(不入库) +.local/ + diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 47fee1d..b7c3f07 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -5,11 +5,11 @@ from __future__ import annotations import asyncio from logging.config import fileConfig -from alembic import context from sqlalchemy import pool from sqlalchemy.engine import Connection from sqlalchemy.ext.asyncio import async_engine_from_config +from alembic import context from app.config import get_settings from app.models import Base diff --git a/backend/alembic/versions/0001_init.py b/backend/alembic/versions/0001_init.py index 42fb558..5e8dbc5 100644 --- a/backend/alembic/versions/0001_init.py +++ b/backend/alembic/versions/0001_init.py @@ -19,57 +19,76 @@ depends_on: str | Sequence[str] | None = None APP_ROLE = "dadao_app" -HELPER_SQL = """ -CREATE OR REPLACE FUNCTION app_ws_id() RETURNS uuid -LANGUAGE sql STABLE AS $$ - SELECT nullif(current_setting('app.ws_id', true), '')::uuid -$$; +# 说明:asyncpg 走"预备语句"执行,**一条 execute 里只能有一条 SQL**, +# 多条语句会报 `cannot insert multiple commands into a prepared statement`。 +# 所以这里按语句拆成元组,逐条执行。 +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 + $$ + """, + """ + CREATE OR REPLACE FUNCTION app_user_id() RETURNS uuid + LANGUAGE sql STABLE AS $$ + SELECT nullif(current_setting('app.user_id', true), '')::uuid + $$ + """, +) -CREATE OR REPLACE FUNCTION app_user_id() RETURNS uuid -LANGUAGE sql STABLE AS $$ - SELECT nullif(current_setting('app.user_id', true), '')::uuid -$$; -""" +POLICY_STATEMENTS: tuple[str, ...] = ( + # ── workspace:本租户 OR 我是其成员(用于"我加入了哪些工作区")── + "ALTER TABLE workspace ENABLE ROW LEVEL SECURITY;", + """ + CREATE POLICY workspace_isolation ON workspace + USING ( + id = app_ws_id() + OR id IN (SELECT m.workspace_id FROM member m WHERE m.user_id = app_user_id()) + ) + WITH CHECK (id = app_ws_id()); + """, + # ── member:本租户成员 OR 我自己那条 ── + "ALTER TABLE member ENABLE ROW LEVEL SECURITY;", + """ + CREATE POLICY member_isolation ON member + USING (workspace_id = app_ws_id() OR user_id = app_user_id()) + WITH CHECK (workspace_id = app_ws_id()); + """, + # ── invitation ── + "ALTER TABLE invitation ENABLE ROW LEVEL SECURITY;", + """ + CREATE POLICY invitation_isolation ON invitation + USING (workspace_id = app_ws_id()) + WITH CHECK (workspace_id = app_ws_id()); + """, + # ── audit_log:只增不改,租户内可见 ── + "ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;", + """ + CREATE POLICY audit_log_isolation ON audit_log + USING (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};" + ), +) -POLICY_SQL = f""" --- ── workspace:本租户 OR 我是其成员(用于"我加入了哪些工作区")── -ALTER TABLE workspace ENABLE ROW LEVEL SECURITY; -CREATE POLICY workspace_isolation ON workspace - USING ( - id = app_ws_id() - OR id IN (SELECT m.workspace_id FROM member m WHERE m.user_id = app_user_id()) - ) - WITH CHECK (id = app_ws_id()); --- ── member:本租户成员 OR 我自己那条 ── -ALTER TABLE member ENABLE ROW LEVEL SECURITY; -CREATE POLICY member_isolation ON member - USING (workspace_id = app_ws_id() OR user_id = app_user_id()) - WITH CHECK (workspace_id = app_ws_id()); - --- ── invitation ── -ALTER TABLE invitation ENABLE ROW LEVEL SECURITY; -CREATE POLICY invitation_isolation ON invitation - USING (workspace_id = app_ws_id()) - WITH CHECK (workspace_id = app_ws_id()); - --- ── audit_log:只增不改,租户内可见 ── -ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY; -CREATE POLICY audit_log_isolation ON audit_log - USING (workspace_id = app_ws_id()) - WITH CHECK (workspace_id = app_ws_id()); - --- ── 授权:应用角色没有 DDL、没有 BYPASSRLS,只能读写到自己该看的数据 ── -GRANT USAGE ON SCHEMA public TO {APP_ROLE}; -GRANT SELECT, INSERT, UPDATE, DELETE ON - workspace, app_user, member, invitation, audit_log TO {APP_ROLE}; -ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public - GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE}; -""" +def _exec_each(statements: tuple[str, ...]) -> None: + for statement in statements: + op.execute(statement) def upgrade() -> None: - op.execute(HELPER_SQL) + _exec_each(HELPER_STATEMENTS) op.create_table( "app_user", @@ -184,7 +203,7 @@ def upgrade() -> None: ) op.create_index("ix_audit_ws_created", "audit_log", ["workspace_id", "created_at"]) - op.execute(POLICY_SQL) + _exec_each(POLICY_STATEMENTS) def downgrade() -> None: @@ -194,4 +213,5 @@ def downgrade() -> None: op.drop_table("member") op.drop_table("workspace") 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();") diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 74b6d94..ba93351 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -13,7 +13,7 @@ from ..config import get_settings from ..deps import CurrentUser, SessionDep from ..errors import AppError, ErrorCode from ..models import AppUser, Member, Workspace -from ..schemas import Envelope, ok +from ..schemas import ok from ..security import create_token_pair, decode_token from ..stores import get_store, sms_code_key, sms_throttle_key @@ -43,9 +43,13 @@ class RefreshIn(BaseModel): @router.post("/sms/send", summary="发送登录验证码") async def send_code(body: SendCodeIn) -> dict: store = get_store() - hits = await store.incr(sms_throttle_key(body.phone), 60) - if hits > 1: - raise AppError(ErrorCode.SMS_TOO_FREQUENT, "发送过于频繁,请 60 秒后重试") + if settings.sms_throttle_seconds > 0: + hits = await store.incr(sms_throttle_key(body.phone), settings.sms_throttle_seconds) + if hits > 1: + raise AppError( + ErrorCode.SMS_TOO_FREQUENT, + f"发送过于频繁,请 {settings.sms_throttle_seconds} 秒后重试", + ) if settings.sms_provider == "dev": code = settings.sms_dev_code diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 3d29b21..ae91ebe 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -20,7 +20,7 @@ class UserOut(BaseModel): class TokenOut(BaseModel): access_token: str refresh_token: str - token_type: str = "bearer" + token_type: str = "bearer" # noqa: S105 - 不是密码,是 OAuth2 的固定字段值 expires_in: int diff --git a/backend/app/api/workspaces.py b/backend/app/api/workspaces.py index 7eff2d3..1ba9d81 100644 --- a/backend/app/api/workspaces.py +++ b/backend/app/api/workspaces.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Query, Request from pydantic import BaseModel, Field @@ -14,7 +14,7 @@ from ..db import bind_tenant from ..deps import AdminDep, CurrentUser, MemberDep, SessionDep, TenantDep from ..errors import AppError, ErrorCode 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 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") session.add(ws) - session.add( - Member(workspace_id=ws_id, user_id=user.id, role="owner", status="active") - ) + # 先刷 workspace 再插 member:ORM 的 flush 顺序只按 relationship 排序, + # 没有 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 audit( session, @@ -156,7 +157,7 @@ async def create_invitation( code=new_invite_code(), role=body.role, 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) await session.flush() @@ -192,19 +193,15 @@ async def join_workspace( ) ) ).scalars() - matched = next( - (inv for inv in invitation if invite_code_matches(body.code, inv.code)), None - ) + matched = next((inv for inv in invitation if invite_code_matches(body.code, inv.code)), None) if matched is None: 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, "邀请码已过期") existing = ( await session.execute( - select(Member).where( - Member.workspace_id == ctx.ws_id, Member.user_id == ctx.user.id - ) + select(Member).where(Member.workspace_id == ctx.ws_id, Member.user_id == ctx.user.id) ) ).scalar_one_or_none() if existing is not None and existing.status == "active": @@ -223,7 +220,7 @@ async def join_workspace( existing.role = matched.role existing.status = "active" - matched.accepted_at = datetime.now(UTC) + matched.accepted_at = datetime.now(timezone.utc) matched.accepted_by = ctx.user.id await session.flush() await audit( diff --git a/backend/app/config.py b/backend/app/config.py index b73c5af..52c0a92 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,7 +9,7 @@ from typing import Literal from pydantic import Field, field_validator 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): @@ -27,9 +27,7 @@ class Settings(BaseSettings): # ── 数据库 ──────────────────────────────────────────── # 应用角色:受 RLS 约束,非 owner、非 superuser - database_url: str = ( - "postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5433/dadaotask" - ) + database_url: str = "postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5433/dadaotask" # 迁移角色:owner,可绕过 RLS,仅 alembic / 运维脚本使用 migration_database_url: str = ( "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 refresh_token_days: int = 14 sms_code_ttl_seconds: int = 300 + # 同一手机号两次发码的最小间隔;测试里设为 0 可关闭节流 + sms_throttle_seconds: int = 60 sms_dev_code: str = "123456" sms_provider: Literal["dev", "aliyun", "tencent"] = "dev" diff --git a/backend/app/db.py b/backend/app/db.py index 0668be8..28b2f3a 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -39,16 +39,13 @@ SessionFactory = async_sessionmaker(engine, expire_on_commit=False, autoflush=Fa async def get_session() -> AsyncIterator[AsyncSession]: """一个请求 = 一个事务。异常即整体回滚。""" - async with SessionFactory() as session: - async with session.begin(): - yield session + async with SessionFactory() as session, session.begin(): + yield session async def bind_tenant(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID | None) -> None: """把租户上下文写进当前事务。必须在任何业务查询之前调用。""" - await session.execute( - text("SELECT set_config('app.ws_id', :ws, true)"), {"ws": str(ws_id)} - ) + await session.execute(text("SELECT set_config('app.ws_id', :ws, true)"), {"ws": str(ws_id)}) if user_id is not None: await session.execute( text("SELECT set_config('app.user_id', :uid, true)"), {"uid": str(user_id)} @@ -65,15 +62,19 @@ async def bind_user(session: AsyncSession, user_id: uuid.UUID) -> None: async def current_app_role(session: AsyncSession) -> dict[str, Any]: """自检用:确认应用连接的不是特权角色。""" row = ( - await session.execute( - text( - "SELECT current_user AS role, " - "pg_has_role(current_user, 'pg_read_all_data', 'member') AS read_all, " - "(SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS is_super, " - "(SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user) AS bypass_rls" + ( + await session.execute( + text( + "SELECT current_user AS role, " + "pg_has_role(current_user, 'pg_read_all_data', 'member') AS read_all, " + "(SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS is_super, " + "(SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user) AS bypass_rls" + ) ) ) - ).mappings().one() + .mappings() + .one() + ) return dict(row) diff --git a/backend/app/logging_setup.py b/backend/app/logging_setup.py index f58b9b7..9eedffd 100644 --- a/backend/app/logging_setup.py +++ b/backend/app/logging_setup.py @@ -10,7 +10,7 @@ import json import logging import sys from contextvars import ContextVar -from datetime import UTC, datetime +from datetime import datetime, timezone from typing import Any request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-") @@ -37,10 +37,14 @@ def _mask(value: Any, depth: int = 0) -> Any: return "" if isinstance(value, dict): 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() } - if isinstance(value, (list, tuple)): + if isinstance(value, list | tuple): return [_mask(item, depth + 1) for item in value] return value @@ -48,7 +52,7 @@ def _mask(value: Any, depth: int = 0) -> Any: class JsonFormatter(logging.Formatter): def format(self, record: logging.LogRecord) -> str: payload: dict[str, Any] = { - "ts": datetime.now(UTC).isoformat(timespec="milliseconds"), + "ts": datetime.now(timezone.utc).isoformat(timespec="milliseconds"), "level": record.levelname, "logger": record.name, "msg": record.getMessage(), diff --git a/backend/app/main.py b/backend/app/main.py index e7fde9d..51d7819 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from contextlib import asynccontextmanager from typing import Any @@ -28,7 +27,10 @@ logger = get_logger("app") @asynccontextmanager async def lifespan(app: FastAPI): 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 await engine.dispose() diff --git a/backend/app/middleware.py b/backend/app/middleware.py index 10c1d25..cdeb220 100644 --- a/backend/app/middleware.py +++ b/backend/app/middleware.py @@ -97,7 +97,9 @@ class IdempotencyMiddleware(BaseHTTPMiddleware): ) response = await call_next(request) - if response.status_code >= 500: + # 只缓存成功响应:4xx 往往是"这次请求本身有问题"(校验失败、权限不足), + # 缓存住会让用户改对参数后仍然拿到同一个错误。 + if response.status_code >= 400: return response body = b"" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 40c9d33..0879947 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -11,19 +11,21 @@ import os os.environ["ENV"] = "test" os.environ.setdefault("SMS_DEV_CODE", "123456") +# 测试里关掉发码节流,否则"同一手机号再登录一次"要等 60 秒 +os.environ.setdefault("SMS_THROTTLE_SECONDS", "0") import uuid from collections.abc import AsyncIterator from typing import Any import pytest -from alembic import command -from alembic.config import Config 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 @@ -82,9 +84,7 @@ 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}) - ) + 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"], @@ -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]: - 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"]) + ) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 91e259a..3644eed 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -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: await client.post("/api/v1/auth/sms/send", json={"phone": "13800000003"}) - resp = await client.post( - "/api/v1/auth/login", json={"phone": "13800000003", "code": "000000"} - ) + resp = await client.post("/api/v1/auth/login", json={"phone": "13800000003", "code": "000000"}) assert resp.status_code == 400 assert resp.json()["code"] == 2010 async def test_code_can_only_be_used_once(client: AsyncClient) -> None: await client.post("/api/v1/auth/sms/send", json={"phone": "13800000004"}) - ok = await client.post( - "/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE} - ) + ok = await client.post("/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE}) assert ok.status_code == 200 - again = await client.post( - "/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE} - ) + again = await client.post("/api/v1/auth/login", json={"phone": "13800000004", "code": DEV_CODE}) 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"} 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) @@ -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: user = await login(client) - resp = await client.post( - "/api/v1/auth/refresh", json={"refresh_token": user["token"]} - ) + resp = await client.post("/api/v1/auth/refresh", json={"refresh_token": user["token"]}) assert resp.status_code == 401 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: user = await login(client) data = unwrap( - await client.post( - "/api/v1/auth/refresh", json={"refresh_token": user["refresh_token"]} - ) + await client.post("/api/v1/auth/refresh", json={"refresh_token": user["refresh_token"]}) ) me = await client.get("/api/v1/auth/me", headers=auth(data["access_token"])) assert me.status_code == 200 diff --git a/backend/tests/test_tenant_isolation.py b/backend/tests/test_tenant_isolation.py index 3f574e3..19befa0 100644 --- a/backend/tests/test_tenant_isolation.py +++ b/backend/tests/test_tenant_isolation.py @@ -13,6 +13,7 @@ import uuid import pytest from httpx import AsyncClient from sqlalchemy import text +from sqlalchemy.exc import ProgrammingError from app.db import SessionFactory, bind_tenant, current_app_role 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: alice, _bob, _ws_a, ws_b = await _two_tenants(client) - resp = await client.get( - f"/api/v1/workspaces/{ws_b['id']}/members", headers=alice["headers"] - ) + resp = await client.get(f"/api/v1/workspaces/{ws_b['id']}/members", headers=alice["headers"]) 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_b_id = uuid.UUID(ws_b["id"]) - async with SessionFactory() as session: - async with session.begin(): - await bind_tenant(session, ws_a_id, None) - visible = ( - await session.execute(text("SELECT id FROM workspace")) - ).scalars().all() - member_ids = ( - await session.execute(text("SELECT workspace_id FROM member")) - ).scalars().all() + async with SessionFactory() as session, session.begin(): + await bind_tenant(session, ws_a_id, None) + visible = (await session.execute(text("SELECT id FROM workspace"))).scalars().all() + member_ids = ( + (await session.execute(text("SELECT workspace_id FROM member"))).scalars().all() + ) assert [str(v) for v in visible] == [ws_a["id"]] assert set(member_ids) == {ws_a_id} @@ -83,9 +79,8 @@ async def test_rls_hides_rows_at_database_level(client: AsyncClient) -> None: async def test_rls_without_context_returns_nothing(client: AsyncClient) -> None: await _two_tenants(client) - async with SessionFactory() as session: - async with session.begin(): - count = (await session.execute(text("SELECT count(*) FROM workspace"))).scalar_one() + async with SessionFactory() as session, session.begin(): + count = (await session.execute(text("SELECT count(*) FROM workspace"))).scalar_one() assert count == 0, "未绑定租户上下文时不应看到任何行" @@ -100,15 +95,14 @@ async def test_rls_enabled_on_every_tenant_table(client: AsyncClient) -> None: {"names": list(TENANT_TABLES)}, ) ).all() - status = {name: enabled for name, enabled in rows} + status = dict(rows) assert set(status) == set(TENANT_TABLES), "有租户表没建出来" 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 with SessionFactory() as session: - async with session.begin(): - role = await current_app_role(session) + async with SessionFactory() as session, session.begin(): + role = await current_app_role(session) assert role["is_super"] is False, "应用角色绝不能是 superuser,否则 RLS 被跳过" assert role["bypass_rls"] is False, "应用角色不能带 BYPASSRLS" assert role["read_all"] is False @@ -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: """应用角色不能建表——否则一次 SQL 注入就能改结构。""" - async with SessionFactory() as session: - async with session.begin(): - with pytest.raises(Exception): - await session.execute(text("CREATE TABLE should_not_exist (id int)")) + async with SessionFactory() as session, session.begin(): + with pytest.raises(ProgrammingError): # 权限不足,DDL 必须失败 + await session.execute(text("CREATE TABLE should_not_exist (id int)"))