✨ feat: WP-07 插件生态——Registry、三态分离与调用门(ADR-001/008)

- 迁移 14:plugin(kind 四形态、撤架三态)、plugin_version(Manifest JSONB + 规范化内容哈希签名、(plugin,version) 唯一)、plugin_installation(enabled + granted_permissions、(domain,plugin) 唯一)
- Registry:发布版本(Manifest 必须声明 permissions,签名=canonical 哈希);撤架阻断新调用与新安装,历史审计保留(验证:撤架后 invoked 审计仍在)
- 三态分离(购买≠安装≠启用≠授权):安装默认 disabled+零授权;启用必须显式授予权限子集(越界 422);调用门逐级校验(未装/未启/撤架各 403)
- 升级:Manifest 新增权限必须重新确认(未确认 422 且明示新增项);确认后升级成功
- 调用:按 Manifest response_template 真实执行(声明式确定性回填);remote/isolated 形态明示 501 随沙箱交付(WP-14),不静默降级;审计含版本哈希(快照语义)
- 权限标识统一 group:item 格式
- 测试 86 项全绿(+4):三态流、越界、升级确认、撤架阻断与历史保留
This commit is contained in:
chenxin committed 2026-09-12 10:18:20 +08:00
1 parent 795df0413e
commit 9ef2568c3b
7 files changed
+771

No files matched your search

+157
View File
@@ -1437,3 +1437,160 @@ class TestWorkspaces:
# 被移除后访问失效
after = await client.get(f"/v1/workspaces/{ws_id}/members", headers=editor)
assert after.status_code == 404
@pytest.mark.usefixtures("client")
class TestPlugins:
MANIFEST_V1 = {
"permissions": {"network": ["api.example.com"], "data": ["public_web"]},
"response_template": "已按声明式规则处理",
}
MANIFEST_V2 = {
"permissions": {
"network": ["api.example.com"],
"data": ["public_web", "local_files"], # 新增权限
},
"response_template": "v2 处理",
}
async def _publish(self, client, headers, version, manifest) -> None:
resp = await client.post(
"/v1/plugins/versions",
json={"name": "example.fetch", "kind": "declarative",
"version": version, "manifest": manifest},
headers=headers,
)
assert resp.status_code == 201, resp.text
async def test_three_state_separation(self, client) -> None:
"""购买≠安装≠启用≠授权:安装默认禁用、零授权;启用显式授权。"""
publisher = await login(client, "pl-pub@example.com")
await self._publish(client, publisher, "1.0.0", self.MANIFEST_V1)
user = await login(client, "pl-user@example.com")
# 未安装直接调用:403
before = await client.post(
"/v1/plugins/example.fetch/invocations", json={"input": {}}, headers=user
)
assert before.status_code == 403
# 安装:默认 disabled
installation = await client.post(
"/v1/plugins/example.fetch/installations",
json={"version": "1.0.0"}, headers=user,
)
assert installation.status_code == 201
assert installation.json()["enabled"] is False
assert installation.json()["granted_permissions"] == []
# 已安装未启用:调用仍拒
disabled = await client.post(
"/v1/plugins/example.fetch/invocations", json={"input": {}}, headers=user
)
assert disabled.status_code == 403
# 启用:显式授权子集
enabled = await client.post(
f"/v1/plugin-installations/{installation.json()['id']}/enablings",
json={"granted_permissions": ["network:api.example.com"]}, headers=user,
)
assert enabled.status_code == 201 and enabled.json()["enabled"] is True
# 调用成功(manifest 模板回填)
invoked = await client.post(
"/v1/plugins/example.fetch/invocations", json={"input": {}}, headers=user
)
assert invoked.status_code == 200
assert invoked.json()["response"] == "已按声明式规则处理"
assert invoked.json()["runtime"] == "declarative"
async def test_grant_outside_manifest_rejected(self, client) -> None:
publisher = await login(client, "pl2-pub@example.com")
await self._publish(client, publisher, "1.0.0", self.MANIFEST_V1)
user = await login(client, "pl2-user@example.com")
installation = await client.post(
"/v1/plugins/example.fetch/installations",
json={"version": "1.0.0"}, headers=user,
)
overreach = await client.post(
f"/v1/plugin-installations/{installation.json()['id']}/enablings",
json={"granted_permissions": ["device:camera"]}, headers=user,
)
assert overreach.status_code == 422 # 授权越界
async def test_upgrade_requires_reconfirmation_of_new_permissions(self, client) -> None:
publisher = await login(client, "pl3-pub@example.com")
await self._publish(client, publisher, "1.0.0", self.MANIFEST_V1)
await self._publish(client, publisher, "2.0.0", self.MANIFEST_V2)
user = await login(client, "pl3-user@example.com")
installation = await client.post(
"/v1/plugins/example.fetch/installations",
json={"version": "1.0.0"}, headers=user,
)
iid = installation.json()["id"]
await client.post(
f"/v1/plugin-installations/{iid}/enablings",
json={"granted_permissions": ["network:api.example.com"]}, headers=user,
)
# 升级不确认新增权限:422
blocked = await client.post(
f"/v1/plugin-installations/{iid}/upgrades",
json={"version": "2.0.0"}, headers=user,
)
assert blocked.status_code == 422
assert "重新确认" in blocked.json()["detail"]
# 带新增授权:成功
ok = await client.post(
f"/v1/plugin-installations/{iid}/upgrades",
json={"version": "2.0.0",
"granted_permissions": ["network:api.example.com", "data:local_files"]},
headers=user,
)
assert ok.status_code == 201
assert ok.json()["granted_permissions"] == [
"data:local_files", "network:api.example.com",
]
async def test_withdrawal_blocks_new_invocations_keeps_history(self, client, db) -> None:
from sqlalchemy import select as sa_select
from app.models import AuditEvent
publisher = await login(client, "pl4-pub@example.com")
await self._publish(client, publisher, "1.0.0", self.MANIFEST_V1)
user = await login(client, "pl4-user@example.com")
installation = await client.post(
"/v1/plugins/example.fetch/installations",
json={"version": "1.0.0"}, headers=user,
)
await client.post(
f"/v1/plugin-installations/{installation.json()['id']}/enablings",
json={"granted_permissions": ["network:api.example.com"]}, headers=user,
)
await client.post(
"/v1/plugins/example.fetch/invocations", json={"input": {}}, headers=user
)
# 撤架(发布者)
withdrawn = await client.post(
"/v1/plugins/example.fetch/withdrawals", headers=publisher
)
assert withdrawn.json()["status"] == "removed"
# 新调用阻断
blocked = await client.post(
"/v1/plugins/example.fetch/invocations", json={"input": {}}, headers=user
)
assert blocked.status_code == 403
# 新安装阻断
new_install = await client.post(
"/v1/plugins/example.fetch/installations",
json={"version": "1.0.0"}, headers=await login(client, "pl4-new@example.com"),
)
assert new_install.status_code == 404
# 历史审计保留
async with db() as session:
audits = list(
(
await session.execute(
sa_select(AuditEvent).where(
AuditEvent.aggregate_id == "example.fetch",
AuditEvent.action == "plugin.invoked",
)
)
).scalars().all()
)
assert len(audits) == 1 # 撤架前那次调用仍在审计链