跑通迁移与测试后修掉的真问题: - 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:
@@ -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"])
|
||||
)
|
||||
|
||||
+11
-16
@@ -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
|
||||
|
||||
@@ -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)"))
|
||||
|
||||
Reference in New Issue
Block a user