✨ 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:
1 parent
795df0413e
commit
9ef2568c3b
7 files changed
+771
No files matched your search
@@ -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 # 撤架前那次调用仍在审计链
|
||||
Reference in new issue
Block a user