✨ feat: WP-12 协作空间——Workspace、RBAC、邀请与项目评审

- 迁移 13:workspace、workspace_member(四角色 + (workspace,member) 唯一)、workspace_invitation(四态流转)、project、project_version((project,version) 唯一、评审四态、提交/评审人留痕)
- RBAC 三权分立:editor 提交版本、reviewer 批准/拒绝、owner 发布与成员管理——权限矩阵代码化(editor 不可自审、viewer 不可提交、发布前必须有 approved 当前版本)
- 邀请流:owner 邀请(角色限定 editor/reviewer/viewer)→ 被邀者接受入成员;已是成员 409;移除所有者 422
- 评审流:版本号自动递增不可变;批准设为当前版本;拒绝不可逆(重审 409);未批准发布 422
- 隔离:非成员与被移除成员统一 404 fail-closed(require_member 单点)
- 测试 82 项全绿(+3):完整协作流(邀请三角色→提交→批准→发布)、拒绝路径、成员移除失效与外人隔离
This commit is contained in:
chenxin committed 2026-09-12 09:33:29 +08:00
1 parent 99985f5855
commit 795df0413e
7 files changed
+944

No files matched your search

+155
View File
@@ -1282,3 +1282,158 @@ class TestCommunity:
b = await login(client, "cm7b@example.com")
resp = await client.post(f"/v1/community/contents/{cid}/withdrawals", headers=b)
assert resp.status_code == 404 # 非作者:fail-closed
@pytest.mark.usefixtures("client")
class TestWorkspaces:
async def _domain_id_of(self, client, headers) -> str:
"""经发布列表拿自己的域 ID(稳定取法)。"""
resp = await client.post(
"/v1/community/contents",
json={"kind": "post", "title": f"定位-{headers['Cookie'][-8:]}"},
headers=headers,
)
cid = resp.json()["id"]
listing = await client.get("/v1/community/contents", headers=headers)
return next(
c["author_domain_id"] for c in listing.json()["data"] if c["id"] == cid
)
async def test_full_collaboration_flow(self, client) -> None:
owner = await login(client, "ws-owner@example.com")
editor = await login(client, "ws-editor@example.com")
reviewer = await login(client, "ws-reviewer@example.com")
viewer = await login(client, "ws-viewer@example.com")
ws = await client.post(
"/v1/workspaces", json={"name": "同契台"}, headers=owner
)
assert ws.status_code == 201, ws.text
ws_id = ws.json()["id"]
# 邀请三角色
invs = {}
for name, hdrs, role in [
("editor", editor, "editor"), ("reviewer", reviewer, "reviewer"),
("viewer", viewer, "viewer"),
]:
did = await self._domain_id_of(client, hdrs)
inv = await client.post(
f"/v1/workspaces/{ws_id}/invitations",
json={"invitee_domain_id": did, "role": role}, headers=owner,
)
assert inv.status_code == 201, inv.text
acc = await client.post(
f"/v1/workspaces/invitations/{inv.json()['id']}/acceptances", headers=hdrs
)
assert acc.status_code == 201 and acc.json()["role"] == role
invs[name] = acc.json()
members = await client.get(f"/v1/workspaces/{ws_id}/members", headers=owner)
assert len(members.json()["data"]) == 4
# 项目与版本评审:editor 提交 → reviewer 批准 → owner 发布
project = await client.post(
f"/v1/workspaces/{ws_id}/projects", json={"name": "世界观设定"}, headers=editor
)
assert project.status_code == 201
pid = project.json()["id"]
v1 = await client.post(
f"/v1/workspaces/projects/{pid}/versions",
json={"payload": {"text": "第一版"}}, headers=editor,
)
assert v1.status_code == 201 and v1.json()["version"] == 1
assert v1.json()["status"] == "submitted"
# viewer 不能提交
denied = await client.post(
f"/v1/workspaces/projects/{pid}/versions",
json={"payload": {"text": "越权"}}, headers=viewer,
)
assert denied.status_code == 403
# editor 不能评审(三权分立)
self_review = await client.post(
f"/v1/workspaces/project-versions/{v1.json()['id']}/reviews",
json={"approve": True}, headers=editor,
)
assert self_review.status_code == 403
# 未批准前 owner 不能发布
early = await client.post(
f"/v1/workspaces/projects/{pid}/publications", headers=owner
)
assert early.status_code == 422
# reviewer 批准 → 设为当前版本
approved = await client.post(
f"/v1/workspaces/project-versions/{v1.json()['id']}/reviews",
json={"approve": True}, headers=reviewer,
)
assert approved.json()["status"] == "approved"
# owner 发布
published = await client.post(
f"/v1/workspaces/projects/{pid}/publications", headers=owner
)
assert published.json()["status"] == "published"
async def test_rejection_path(self, client) -> None:
owner = await login(client, "ws2-owner@example.com")
editor = await login(client, "ws2-editor@example.com")
reviewer = await login(client, "ws2-reviewer@example.com")
ws = await client.post("/v1/workspaces", json={"name": "拒绝路径"}, headers=owner)
ws_id = ws.json()["id"]
for hdrs, role in [(editor, "editor"), (reviewer, "reviewer")]:
did = await self._domain_id_of(client, hdrs)
inv = await client.post(
f"/v1/workspaces/{ws_id}/invitations",
json={"invitee_domain_id": did, "role": role}, headers=owner,
)
await client.post(
f"/v1/workspaces/invitations/{inv.json()['id']}/acceptances", headers=hdrs
)
project = await client.post(
f"/v1/workspaces/{ws_id}/projects", json={"name": "P"}, headers=editor
)
pid = project.json()["id"]
v1 = await client.post(
f"/v1/workspaces/projects/{pid}/versions",
json={"payload": {"text": "v1"}}, headers=editor,
)
rejected = await client.post(
f"/v1/workspaces/project-versions/{v1.json()['id']}/reviews",
json={"approve": False}, headers=reviewer,
)
assert rejected.json()["status"] == "rejected"
# 拒绝后重审 409
again = await client.post(
f"/v1/workspaces/project-versions/{v1.json()['id']}/reviews",
json={"approve": True}, headers=reviewer,
)
assert again.status_code == 409
# 被拒版本不能作为当前版本发布
pub = await client.post(
f"/v1/workspaces/projects/{pid}/publications", headers=owner
)
assert pub.status_code == 422
async def test_member_removal_and_isolation(self, client) -> None:
owner = await login(client, "ws3-owner@example.com")
editor = await login(client, "ws3-editor@example.com")
outsider = await login(client, "ws3-outsider@example.com")
ws = await client.post("/v1/workspaces", json={"name": "隔离"}, headers=owner)
ws_id = ws.json()["id"]
did = await self._domain_id_of(client, editor)
inv = await client.post(
f"/v1/workspaces/{ws_id}/invitations",
json={"invitee_domain_id": did, "role": "editor"}, headers=owner,
)
member = await client.post(
f"/v1/workspaces/invitations/{inv.json()['id']}/acceptances", headers=editor
)
# 外人访问 404
leak = await client.get(f"/v1/workspaces/{ws_id}/members", headers=outsider)
assert leak.status_code == 404
# owner 移除成员
removed = await client.request(
"DELETE",
f"/v1/workspaces/{ws_id}/members/{member.json()['id']}",
headers=owner,
)
assert removed.status_code == 204
# 被移除后访问失效
after = await client.get(f"/v1/workspaces/{ws_id}/members", headers=editor)
assert after.status_code == 404