feat(m0): 后端骨架 + Web/安卓前端骨架 + 设计文档
- backend: FastAPI 应用工厂、统一错误信封、JWT、短信登录、工作区/成员/邀请、幂等中间件 - alembic 0001: workspace/member/invitation/audit_log/app_user + RLS 策略 + 双角色 GRANT - tests: 认证/工作区/租户隔离/幂等 四组用例(尚未在本机执行) - web: React18+Vite 骨架(登录、工作区、邀请落地页) - android: Compose 骨架(登录、工作区列表) - 2.Design: PRODUCT-PLAN / ROADMAP-M0-M1 / NAMING / DEVELOPMENT
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
"""测试夹具。
|
||||
|
||||
约定:
|
||||
- 一律走真实 HTTP 栈(ASGITransport),不做 handler 级 mock —— 中间件/依赖/RLS 都是被测对象。
|
||||
- 每个用例前清库,避免串味;清库走**迁移角色**(app 角色没有 TRUNCATE 权限,这是设计使然)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ["ENV"] = "test"
|
||||
os.environ.setdefault("SMS_DEV_CODE", "123456")
|
||||
|
||||
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 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 = "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.migration_database_url, poolclass=NullPool)
|
||||
async with owner_engine.begin() as conn:
|
||||
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"]))
|
||||
@@ -0,0 +1,88 @@
|
||||
"""认证相关。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from .conftest import DEV_CODE, auth, login, unwrap
|
||||
|
||||
|
||||
async def test_login_creates_user_and_me_works(client: AsyncClient) -> None:
|
||||
user = await login(client, "13800000001")
|
||||
me = unwrap(await client.get("/api/v1/auth/me", headers=user["headers"]))
|
||||
assert me["user"]["phone"] == "138****0001" # 手机号对外必须打码
|
||||
assert me["workspaces"] == []
|
||||
|
||||
|
||||
async def test_login_is_idempotent_for_same_phone(client: AsyncClient) -> None:
|
||||
first = await login(client, "13800000002")
|
||||
second = await login(client, "13800000002")
|
||||
assert first["user_id"] == second["user_id"]
|
||||
|
||||
|
||||
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"}
|
||||
)
|
||||
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}
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
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:
|
||||
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)
|
||||
assert second.json()["code"] == 2011
|
||||
|
||||
|
||||
async def test_refresh_token_cannot_be_used_as_access_token(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
resp = await client.get("/api/v1/auth/me", headers=auth(user["refresh_token"]))
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 2003
|
||||
|
||||
|
||||
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"]}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 2003
|
||||
|
||||
|
||||
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"]}
|
||||
)
|
||||
)
|
||||
me = await client.get("/api/v1/auth/me", headers=auth(data["access_token"]))
|
||||
assert me.status_code == 200
|
||||
|
||||
|
||||
async def test_requires_token(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/auth/me")
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["code"] == 2001
|
||||
assert resp.json()["request_id"] != "-"
|
||||
|
||||
|
||||
async def test_unknown_path_returns_envelope(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/nope")
|
||||
assert resp.status_code == 404
|
||||
assert set(resp.json()) == {"code", "message", "data", "request_id"}
|
||||
@@ -0,0 +1,64 @@
|
||||
"""幂等键:客户端网络抖动重试时,不能建出两个工作区。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from .conftest import login
|
||||
|
||||
|
||||
async def test_same_key_returns_first_result(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
headers = {**user["headers"], "Idempotency-Key": "k-1"}
|
||||
payload = {"name": "幂等工作区"}
|
||||
|
||||
first = await client.post("/api/v1/workspaces", json=payload, headers=headers)
|
||||
second = await client.post("/api/v1/workspaces", json=payload, headers=headers)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert first.json()["data"]["id"] == second.json()["data"]["id"]
|
||||
assert second.headers.get("X-Idempotent-Replay") == "1"
|
||||
|
||||
listed = await client.get("/api/v1/workspaces", headers=user["headers"])
|
||||
assert len(listed.json()["data"]["items"]) == 1
|
||||
|
||||
|
||||
async def test_different_keys_create_different_workspaces(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
a = await client.post(
|
||||
"/api/v1/workspaces",
|
||||
json={"name": "一号"},
|
||||
headers={**user["headers"], "Idempotency-Key": "k-a"},
|
||||
)
|
||||
b = await client.post(
|
||||
"/api/v1/workspaces",
|
||||
json={"name": "二号"},
|
||||
headers={**user["headers"], "Idempotency-Key": "k-b"},
|
||||
)
|
||||
assert a.json()["data"]["id"] != b.json()["data"]["id"]
|
||||
|
||||
|
||||
async def test_errors_are_not_cached(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
headers = {**user["headers"], "Idempotency-Key": "k-err"}
|
||||
bad = await client.post("/api/v1/workspaces", json={"name": ""}, headers=headers)
|
||||
assert bad.status_code == 422
|
||||
good = await client.post("/api/v1/workspaces", json={"name": "修好了"}, headers=headers)
|
||||
assert good.status_code == 200
|
||||
assert "X-Idempotent-Replay" not in good.headers
|
||||
|
||||
|
||||
async def test_keys_are_scoped_per_user(client: AsyncClient) -> None:
|
||||
alice = await login(client)
|
||||
bob = await login(client)
|
||||
a = await client.post(
|
||||
"/api/v1/workspaces",
|
||||
json={"name": "Alice 的"},
|
||||
headers={**alice["headers"], "Idempotency-Key": "shared"},
|
||||
)
|
||||
b = await client.post(
|
||||
"/api/v1/workspaces",
|
||||
json={"name": "Bob 的"},
|
||||
headers={**bob["headers"], "Idempotency-Key": "shared"},
|
||||
)
|
||||
assert a.json()["data"]["id"] != b.json()["data"]["id"]
|
||||
@@ -0,0 +1,122 @@
|
||||
"""跨租户隔离 —— 本仓库**最重要的测试**(AGENTS.md 2.5)。
|
||||
|
||||
三层验证,任何一层失效都算事故:
|
||||
1. 接口层:A 租户的令牌打不到 B 租户的资源。
|
||||
2. 数据层:应用角色连库、绑定 A 的上下文,物理上看不到 B 的行(RLS 生效)。
|
||||
3. 配置层:RLS 确实 ENABLE,且应用角色不是 superuser / BYPASSRLS(否则 RLS 被跳过)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import SessionFactory, bind_tenant, current_app_role
|
||||
from app.models import TENANT_TABLES
|
||||
|
||||
from .conftest import create_workspace, login, unwrap
|
||||
|
||||
|
||||
async def _two_tenants(client: AsyncClient):
|
||||
alice = await login(client, "13900000001")
|
||||
bob = await login(client, "13900000002")
|
||||
ws_alice = await create_workspace(client, alice, "Alice 的团队")
|
||||
ws_bob = await create_workspace(client, bob, "Bob 的团队")
|
||||
return alice, bob, ws_alice, ws_bob
|
||||
|
||||
|
||||
async def test_cannot_read_other_workspace(client: AsyncClient) -> None:
|
||||
alice, _bob, _ws_a, ws_b = await _two_tenants(client)
|
||||
resp = await client.get(f"/api/v1/workspaces/{ws_b['id']}", headers=alice["headers"])
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["code"] == 3003
|
||||
|
||||
|
||||
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"]
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_cannot_invite_into_other_workspace(client: AsyncClient) -> None:
|
||||
alice, _bob, _ws_a, ws_b = await _two_tenants(client)
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws_b['id']}/invitations",
|
||||
json={"role": "member"},
|
||||
headers=alice["headers"],
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_my_workspace_list_is_scoped(client: AsyncClient) -> None:
|
||||
alice, _bob, ws_a, ws_b = await _two_tenants(client)
|
||||
mine = unwrap(await client.get("/api/v1/workspaces", headers=alice["headers"]))
|
||||
ids = [w["id"] for w in mine["items"]]
|
||||
assert ids == [ws_a["id"]]
|
||||
assert ws_b["id"] not in ids
|
||||
|
||||
|
||||
async def test_rls_hides_rows_at_database_level(client: AsyncClient) -> None:
|
||||
_alice, _bob, ws_a, ws_b = await _two_tenants(client)
|
||||
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()
|
||||
|
||||
assert [str(v) for v in visible] == [ws_a["id"]]
|
||||
assert set(member_ids) == {ws_a_id}
|
||||
assert ws_b_id not in set(member_ids)
|
||||
|
||||
|
||||
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()
|
||||
assert count == 0, "未绑定租户上下文时不应看到任何行"
|
||||
|
||||
|
||||
async def test_rls_enabled_on_every_tenant_table(client: AsyncClient) -> None:
|
||||
async with SessionFactory() as session:
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT relname, relrowsecurity FROM pg_class "
|
||||
"WHERE relname = ANY(:names) AND relkind = 'r'"
|
||||
),
|
||||
{"names": list(TENANT_TABLES)},
|
||||
)
|
||||
).all()
|
||||
status = {name: enabled for name, enabled in 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)
|
||||
assert role["is_super"] is False, "应用角色绝不能是 superuser,否则 RLS 被跳过"
|
||||
assert role["bypass_rls"] is False, "应用角色不能带 BYPASSRLS"
|
||||
assert role["read_all"] is False
|
||||
|
||||
|
||||
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)"))
|
||||
@@ -0,0 +1,175 @@
|
||||
"""工作区 / 成员 / 邀请。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from .conftest import auth, create_workspace, login, unwrap
|
||||
|
||||
|
||||
async def test_create_workspace_makes_creator_owner(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "达道科技")
|
||||
assert ws["role"] == "owner"
|
||||
assert ws["plan"] == "free"
|
||||
assert ws["slug"].startswith("ws-")
|
||||
|
||||
|
||||
async def test_workspace_appears_in_my_list_and_me(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "个人工作区")
|
||||
|
||||
listed = unwrap(await client.get("/api/v1/workspaces", headers=user["headers"]))
|
||||
assert [w["id"] for w in listed["items"]] == [ws["id"]]
|
||||
|
||||
me = unwrap(await client.get("/api/v1/auth/me", headers=user["headers"]))
|
||||
assert me["workspaces"][0]["role"] == "owner"
|
||||
|
||||
|
||||
async def test_members_list_contains_owner_with_masked_phone(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "团队A")
|
||||
page = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/members", headers=user["headers"])
|
||||
)
|
||||
assert page["has_more"] is False
|
||||
member = page["items"][0]
|
||||
assert member["role"] == "owner"
|
||||
assert "****" in member["phone"]
|
||||
|
||||
|
||||
async def test_invite_and_join_flow(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
guest = await login(client)
|
||||
ws = await create_workspace(client, owner, "协作空间")
|
||||
|
||||
invite = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "member", "expires_hours": 24},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert len(invite["code"]) == 8
|
||||
|
||||
joined = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": invite["code"].lower()}, # 邀请码大小写不敏感
|
||||
headers=guest["headers"],
|
||||
)
|
||||
)
|
||||
assert joined["role"] == "member"
|
||||
|
||||
page = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/members", headers=guest["headers"])
|
||||
)
|
||||
assert {m["role"] for m in page["items"]} == {"owner", "member"}
|
||||
|
||||
|
||||
async def test_invite_code_single_use(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
second = await login(client)
|
||||
third = await login(client)
|
||||
ws = await create_workspace(client, owner, "限量空间")
|
||||
invite = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "member"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": invite["code"]},
|
||||
headers=second["headers"],
|
||||
)
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": invite["code"]},
|
||||
headers=third["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["code"] == 3010
|
||||
|
||||
|
||||
async def test_bad_invite_code_rejected(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
guest = await login(client)
|
||||
ws = await create_workspace(client, owner, "封闭空间")
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": "ZZZZZZZZ"},
|
||||
headers=guest["headers"],
|
||||
)
|
||||
assert resp.json()["code"] == 3010
|
||||
|
||||
|
||||
async def test_plain_member_cannot_invite(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
member = await login(client)
|
||||
ws = await create_workspace(client, owner, "权限空间")
|
||||
invite = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "member"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": invite["code"]},
|
||||
headers=member["headers"],
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "member"},
|
||||
headers=member["headers"],
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["code"] == 3004
|
||||
|
||||
|
||||
async def test_only_owner_can_invite_admin(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
admin = await login(client)
|
||||
ws = await create_workspace(client, owner, "治理空间")
|
||||
|
||||
first = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "admin"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": first["code"]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "admin"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.json()["code"] == 3004
|
||||
|
||||
|
||||
async def test_duplicate_slug_conflict(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
await client.post(
|
||||
"/api/v1/workspaces", json={"name": "占位", "slug": "taken"}, headers=user["headers"]
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/v1/workspaces", json={"name": "抢占", "slug": "taken"}, headers=user["headers"]
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["code"] == 1009
|
||||
|
||||
|
||||
async def test_requires_auth(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/workspaces")
|
||||
assert resp.status_code == 401
|
||||
assert auth("bad.token.here") # 保证 helper 可用
|
||||
Reference in New Issue
Block a user