97 lines
3.6 KiB
Python
97 lines
3.6 KiB
Python
|
|
# ============================================================
|
|||
|
|
# Explore 与 Runtime 数据边界黄金测试(plan.md §5.1 / 矩阵 55 行)
|
|||
|
|
# 覆盖:主干只读视图写保护(fail closed)、统一沙盒通道隔离、
|
|||
|
|
# Explore 工具无法取得主干写能力(越权与污染测试)。
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
|
|||
|
|
from server.aps_domain.explore_boundary import readonly_view, run_explore
|
|||
|
|
from server.state.seed import seed_world
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _world():
|
|||
|
|
w = seed_world()
|
|||
|
|
w["salesOrders"].append({"id": "so-1", "orderNo": "SO-1"})
|
|||
|
|
return w
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_readonly_view_blocks_top_level_write():
|
|||
|
|
"""主干只读视图:尝试写顶层键必须抛 PermissionError(fail closed)。"""
|
|||
|
|
world = _world()
|
|||
|
|
view = readonly_view(world)
|
|||
|
|
for op in (
|
|||
|
|
lambda v: v.__setitem__("salesOrders", []),
|
|||
|
|
lambda v: v.__delitem__("salesOrders"),
|
|||
|
|
lambda v: v.setdefault("x", 1),
|
|||
|
|
lambda v: v.update({"y": 2}),
|
|||
|
|
lambda v: v.clear(),
|
|||
|
|
):
|
|||
|
|
try:
|
|||
|
|
op(view)
|
|||
|
|
raise AssertionError("应抛 PermissionError")
|
|||
|
|
except PermissionError:
|
|||
|
|
pass
|
|||
|
|
# 主干未被污染
|
|||
|
|
assert world["salesOrders"], "只读视图不应能改动主干"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_readonly_view_blocks_nested_write():
|
|||
|
|
"""嵌套容器:列表 append / dict set 同样被拦截(递归只读视图)。"""
|
|||
|
|
world = _world()
|
|||
|
|
view = readonly_view(world)
|
|||
|
|
orders = view["salesOrders"] # 应返回只读列表
|
|||
|
|
n_before = len(world["salesOrders"])
|
|||
|
|
try:
|
|||
|
|
orders.append({"id": "hack"})
|
|||
|
|
raise AssertionError("嵌套写应抛 PermissionError")
|
|||
|
|
except PermissionError:
|
|||
|
|
pass
|
|||
|
|
assert len(world["salesOrders"]) == n_before, "嵌套写不应影响主干"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_run_explore_sandbox_isolates_world():
|
|||
|
|
"""统一通道:fn 拿到的沙盒可写,但主干完全不改变(深拷贝隔离)。"""
|
|||
|
|
world = _world()
|
|||
|
|
before = json.dumps(world, ensure_ascii=False, sort_keys=True)
|
|||
|
|
before_count = len(world["salesOrders"])
|
|||
|
|
|
|||
|
|
def explorer(sandbox):
|
|||
|
|
sandbox["salesOrders"].append({"id": "sandbox-only"})
|
|||
|
|
sandbox["newKey"] = "explore"
|
|||
|
|
return len(sandbox["salesOrders"])
|
|||
|
|
|
|||
|
|
result = run_explore(world, explorer)
|
|||
|
|
assert result == len(world["salesOrders"]) + 1 # 沙盒内多 1 条
|
|||
|
|
assert len(world["salesOrders"]) == before_count # 主干不变
|
|||
|
|
assert "newKey" not in world
|
|||
|
|
assert json.dumps(world, ensure_ascii=False, sort_keys=True) == before
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_run_explore_nested_mutation_isolated():
|
|||
|
|
"""嵌套变异(列表内 dict 修改)在沙盒内生效、主干不变。"""
|
|||
|
|
world = _world()
|
|||
|
|
|
|||
|
|
def explorer(sandbox):
|
|||
|
|
sandbox["salesOrders"][0]["orderNo"] = "CHANGED"
|
|||
|
|
sandbox["salesOrders"][0]["extra"] = True
|
|||
|
|
return sandbox["salesOrders"][0]["orderNo"]
|
|||
|
|
|
|||
|
|
result = run_explore(world, explorer)
|
|||
|
|
assert result == "CHANGED"
|
|||
|
|
assert any(o["orderNo"] == "SO-1" for o in world["salesOrders"]), "主干 orderNo 不变"
|
|||
|
|
assert all("extra" not in o for o in world["salesOrders"]), "主干无额外字段"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_explore_tool_cannot_gain_main_write():
|
|||
|
|
"""越权测试:Explore 工具即使拿到 view 也无法绕过只读边界写主干。"""
|
|||
|
|
world = _world()
|
|||
|
|
view = readonly_view(world)
|
|||
|
|
try:
|
|||
|
|
view["salesOrders"] = [] # 工具尝试直接替换主干表
|
|||
|
|
raise AssertionError("应抛 PermissionError")
|
|||
|
|
except PermissionError:
|
|||
|
|
pass
|
|||
|
|
assert world["salesOrders"], "主干写能力必须被切断"
|