✨ 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:
1 parent
99985f5855
commit
795df0413e
7 files changed
+944
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user