350 lines
14 KiB
Python
350 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import copy
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from server.agent_core import harness
|
|
from server.agent_core.approval_store import ApprovalStore
|
|
from server.aps_domain.folder_pack import folder_schedule_world_fingerprint
|
|
from server.aps_domain.workflow import execute_confirmed, handle_intent
|
|
from server.auth.context import IdentityContext, bind_identity, reset_identity
|
|
from server.contracts import IntentResult
|
|
from server.state.seed import seed_world
|
|
|
|
_SQL = """
|
|
CREATE TABLE `pl_order` (
|
|
`id` int, `code` varchar(64), `business_code` varchar(64),
|
|
`customer_business_code` varchar(64), `material_code` varchar(64),
|
|
`material_name` varchar(64), `parent_code` varchar(64), `craftl_code` varchar(64),
|
|
`machine_model` varchar(64), `dept` varchar(64), `people` varchar(64),
|
|
`drawing_code` varchar(64), `quantity` decimal(10,2), `man_quantity` decimal(10,2),
|
|
`all_quantity` decimal(10,2), `status` varchar(8), `stock_status` varchar(8),
|
|
`distribution_status` varchar(8), `type` varchar(8), `description` varchar(64),
|
|
`planned_end_time` datetime, `is_delete` varchar(8), `level` int
|
|
) ENGINE=InnoDB;
|
|
CREATE TABLE `r_production_craftl` (
|
|
`id` int, `craftl_code` varchar(64), `craftl_name` varchar(64),
|
|
`procedure_code` varchar(64), `procedure_name` varchar(64),
|
|
`output_material_code` varchar(64), `output_material_name` varchar(64),
|
|
`working_hours` decimal(10,2), `sort_no` int, `is_delete` varchar(8)
|
|
) ENGINE=InnoDB;
|
|
CREATE TABLE `md_equipment` (
|
|
`id` int, `code` varchar(64), `name` varchar(64), `is_delete` varchar(8)
|
|
) ENGINE=InnoDB;
|
|
CREATE TABLE `md_material` (
|
|
`id` int, `code` varchar(64), `name` varchar(64), `type` varchar(32),
|
|
`unit` varchar(8), `is_delete` varchar(8)
|
|
) ENGINE=InnoDB;
|
|
INSERT INTO `pl_order` VALUES
|
|
(1,'ORD1',NULL,'C1','FG001','成品A',NULL,'CRAFT1',NULL,NULL,NULL,NULL,10,NULL,NULL,'0','0','0','S',NULL,'2026-08-30 00:00:00','0',5);
|
|
INSERT INTO `r_production_craftl` VALUES
|
|
(1,'CRAFT1','成品A','OP10','粗车','WIP-X','半成品',0.5,10,'0'),
|
|
(2,'CRAFT1','成品A','OP20','精车','FG001','成品A',0.8,20,'0');
|
|
INSERT INTO `md_equipment` VALUES (1,'EQ1','车床','0');
|
|
INSERT INTO `md_material` VALUES (1,'FG001','成品A','成品','件','0');
|
|
"""
|
|
|
|
|
|
class _ProjectStore:
|
|
def __init__(self, root: Path) -> None:
|
|
self.root = root
|
|
|
|
def snapshot(self, include_messages: bool = False) -> dict[str, Any]:
|
|
result = {
|
|
"projects": [{"id": "p1", "name": "安全目录", "workDir": str(self.root)}],
|
|
"sessions": [{"id": "s1", "projectId": "p1"}],
|
|
"files": [],
|
|
"activeProjectId": "p1",
|
|
"activeSessionId": "s1",
|
|
}
|
|
if include_messages:
|
|
result["messages"] = {"s1": []}
|
|
return result
|
|
|
|
def active_world_key(self) -> str:
|
|
return "p1"
|
|
|
|
def require_active_write(self) -> None:
|
|
return None
|
|
|
|
def replace_workspace(self, _payload: dict[str, Any]) -> dict[str, Any]:
|
|
return self.snapshot(include_messages=True)
|
|
|
|
|
|
class _CheckpointStore:
|
|
def __init__(self) -> None:
|
|
self.pairs: dict[str, dict[str, Any]] = {}
|
|
|
|
def create(self, world: dict[str, Any], **kwargs: Any) -> dict[str, Any]:
|
|
pair_id = f"pair-folder-{len(self.pairs) + 1}"
|
|
pair = {"pairId": pair_id, "world": copy.deepcopy(world), **kwargs}
|
|
self.pairs[pair_id] = pair
|
|
return pair
|
|
|
|
def get(self, pair_id: str) -> dict[str, Any] | None:
|
|
return copy.deepcopy(self.pairs.get(pair_id))
|
|
|
|
def delete(self, pair_id: str) -> bool:
|
|
return self.pairs.pop(pair_id, None) is not None
|
|
|
|
|
|
class _Store:
|
|
def __init__(self, tenant_uuid: str) -> None:
|
|
self.data = seed_world()
|
|
self.tenant_uuid = tenant_uuid
|
|
self.world_key = "p1"
|
|
self.path = "memory://folder-security"
|
|
self._ids: dict[str, int] = {}
|
|
self.save_count = 0
|
|
self.fail_next_save = False
|
|
|
|
def next_id(self, kind: str) -> int:
|
|
self._ids[kind] = self._ids.get(kind, 10_000) + 1
|
|
return self._ids[kind]
|
|
|
|
def save(self) -> None:
|
|
self.save_count += 1
|
|
if self.fail_next_save:
|
|
self.fail_next_save = False
|
|
raise OSError("injected folder save failure")
|
|
|
|
|
|
@pytest.fixture
|
|
def folder_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
sql_path = tmp_path / "mini.sql"
|
|
sql_path.write_text(_SQL, encoding="utf-8")
|
|
tenant = "tenant-folder-security"
|
|
identity = IdentityContext(
|
|
user_id=2001,
|
|
username="planner",
|
|
fullname="Planner",
|
|
tenant_uuid=tenant,
|
|
roles=("planner", "approver", "admin"),
|
|
)
|
|
project_store = _ProjectStore(tmp_path)
|
|
checkpoints = _CheckpointStore()
|
|
store = _Store(tenant)
|
|
approval_store = ApprovalStore(str(tmp_path / "approvals.json"))
|
|
original_approval_store = harness._approval_store
|
|
harness.configure_approval_store(store=approval_store)
|
|
|
|
import server.aps_domain.workflow as workflow_module
|
|
from server.agent_core import plan_orchestration
|
|
|
|
monkeypatch.setattr("server.state.projects.get_project_store", lambda: project_store)
|
|
monkeypatch.setattr(workflow_module, "get_checkpoints", lambda: checkpoints)
|
|
monkeypatch.setattr(plan_orchestration, "stage_plan_node", lambda **_kwargs: None)
|
|
monkeypatch.setattr(plan_orchestration, "decide_plan_node", lambda **_kwargs: None)
|
|
try:
|
|
yield store, identity, approval_store, checkpoints, sql_path
|
|
finally:
|
|
harness.configure_approval_store(store=original_approval_store)
|
|
|
|
|
|
def _stage(store: _Store, identity: IdentityContext) -> tuple[str, Any]:
|
|
token = bind_identity(identity)
|
|
try:
|
|
reply = asyncio.run(handle_intent(
|
|
store,
|
|
"s1",
|
|
IntentResult(
|
|
intent="folder.schedule",
|
|
params={"query": "根据这些数据排产"},
|
|
confidence=1.0,
|
|
source="RULE_FAST",
|
|
),
|
|
actor="planner",
|
|
))
|
|
finally:
|
|
reset_identity(token)
|
|
card = next(block for block in reply.blocks if block.type == "confirm-card")
|
|
return str(card.props["confirmId"]), reply
|
|
|
|
|
|
def _approve(store: _Store, identity: IdentityContext, confirm_id: str) -> str:
|
|
token = bind_identity(identity)
|
|
try:
|
|
return execute_confirmed(store, confirm_id, True, actor="planner")
|
|
finally:
|
|
reset_identity(token)
|
|
|
|
|
|
def test_sql_folder_schedule_stage_is_read_only_and_always_p2(folder_env):
|
|
store, identity, approvals, checkpoints, _sql_path = folder_env
|
|
before = folder_schedule_world_fingerprint(store.data)
|
|
|
|
confirm_id, reply = _stage(store, identity)
|
|
|
|
assert folder_schedule_world_fingerprint(store.data) == before
|
|
assert not store.data.get("flexScheduleVersions")
|
|
assert all(event.get("action") != "folder.schedule.sql_direct" for event in store.data["auditEvents"])
|
|
pending = approvals.pending[confirm_id]
|
|
params = pending["params"]
|
|
assert params["sqlApplied"] is True and params["sqlPayload"]
|
|
assert params["sourceManifest"] and params["sourceManifestDigest"]
|
|
assert params["folderWorldFingerprint"] == before
|
|
assert params["folderPayloadDigest"]
|
|
assert params["beforeSnapshot"] == pending["beforeSnapshot"]
|
|
assert checkpoints.get(pending["beforeSnapshot"]) is not None
|
|
assert {
|
|
f"folder-source:{params['sourceManifestDigest']}",
|
|
f"folder-world:{params['folderWorldFingerprint']}",
|
|
f"folder-payload:{params['folderPayloadDigest']}",
|
|
f"folder-snapshot:{params['beforeSnapshot']}",
|
|
} <= set(pending["evidenceRefs"])
|
|
assert "请确认" in reply.text
|
|
|
|
|
|
def test_folder_schedule_allows_missing_to_empty_context_policy_initialization(folder_env):
|
|
store, identity, _approvals, _checkpoints, _sql_path = folder_env
|
|
store.data.pop("contextPolicies", None)
|
|
confirm_id, _reply = _stage(store, identity)
|
|
store.data["contextPolicies"] = {}
|
|
|
|
message = _approve(store, identity, confirm_id)
|
|
|
|
assert "证据校验未通过" not in message
|
|
assert "已按目录数据试排一版" in message
|
|
|
|
|
|
def test_folder_schedule_rejects_nonempty_context_policy_drift(folder_env):
|
|
store, identity, _approvals, _checkpoints, _sql_path = folder_env
|
|
store.data.pop("contextPolicies", None)
|
|
confirm_id, _reply = _stage(store, identity)
|
|
store.data["contextPolicies"] = {"session": {"maxTokens": 1}}
|
|
|
|
message = _approve(store, identity, confirm_id)
|
|
|
|
assert "目标世界已变化" in message
|
|
assert not store.data.get("flexScheduleVersions")
|
|
|
|
|
|
def test_folder_schedule_source_drift_is_denied_without_business_write(folder_env):
|
|
store, identity, _approvals, _checkpoints, sql_path = folder_env
|
|
confirm_id, _reply = _stage(store, identity)
|
|
before = harness.world_fingerprint(store.data)
|
|
sql_path.write_text(_SQL + "\n-- changed after approval stage\n", encoding="utf-8")
|
|
|
|
message = _approve(store, identity, confirm_id)
|
|
|
|
assert "源文件已变化" in message
|
|
assert harness.world_fingerprint(store.data) == before
|
|
assert not store.data.get("flexScheduleVersions")
|
|
assert store.data["auditEvents"][-1]["action"] == "folder.schedule.evidence.denied"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field",
|
|
[
|
|
"sessionId",
|
|
"projectId",
|
|
"targetWorldKey",
|
|
"boundAction",
|
|
"folderPayloadDigest",
|
|
"beforeSnapshot",
|
|
],
|
|
)
|
|
def test_folder_schedule_tampered_envelope_is_denied(folder_env, field: str):
|
|
store, identity, approvals, _checkpoints, _sql_path = folder_env
|
|
confirm_id, _reply = _stage(store, identity)
|
|
before = harness.world_fingerprint(store.data)
|
|
approvals.pending[confirm_id]["params"][field] = f"tampered-{field}"
|
|
approvals.save()
|
|
|
|
message = _approve(store, identity, confirm_id)
|
|
|
|
assert "证据校验未通过" in message
|
|
assert harness.world_fingerprint(store.data) == before
|
|
assert not store.data.get("flexScheduleVersions")
|
|
|
|
|
|
def test_folder_schedule_action_substitution_is_denied(folder_env):
|
|
store, identity, approvals, _checkpoints, _sql_path = folder_env
|
|
confirm_id, _reply = _stage(store, identity)
|
|
before = harness.world_fingerprint(store.data)
|
|
approvals.pending[confirm_id]["action"] = "data.reset"
|
|
approvals.save()
|
|
|
|
message = _approve(store, identity, confirm_id)
|
|
|
|
assert "确认信封证据校验未通过" in message
|
|
assert harness.world_fingerprint(store.data) == before
|
|
assert store.data["auditEvents"][-1]["action"] == "approval.envelope.denied"
|
|
|
|
|
|
@pytest.mark.parametrize("failure", ["import", "schedule", "save"])
|
|
def test_folder_schedule_execution_failures_are_atomic(folder_env, monkeypatch, failure: str):
|
|
store, identity, _approvals, _checkpoints, _sql_path = folder_env
|
|
confirm_id, _reply = _stage(store, identity)
|
|
before = harness.world_fingerprint(store.data)
|
|
|
|
if failure == "import":
|
|
def fail_import(*_args, **_kwargs):
|
|
raise RuntimeError("injected folder import failure")
|
|
monkeypatch.setattr("server.importers.sql_pack.apply_sql_pack_to_world", fail_import)
|
|
elif failure == "schedule":
|
|
def fail_schedule(*_args, **_kwargs):
|
|
raise RuntimeError("injected folder schedule failure")
|
|
monkeypatch.setattr("server.aps_domain.flex.run_flex_schedule", fail_schedule)
|
|
else:
|
|
store.fail_next_save = True
|
|
|
|
message = _approve(store, identity, confirm_id)
|
|
|
|
assert "失败" in message and ("回滚" in message or "恢复" in message)
|
|
assert harness.world_fingerprint(store.data) == before
|
|
assert not store.data.get("flexScheduleVersions")
|
|
assert store.data["auditEvents"][-1]["result"] == "DENIED"
|
|
|
|
|
|
def test_folder_schedule_unchanged_frozen_sql_commits_once(folder_env):
|
|
store, identity, _approvals, _checkpoints, _sql_path = folder_env
|
|
confirm_id, _reply = _stage(store, identity)
|
|
|
|
message = _approve(store, identity, confirm_id)
|
|
|
|
assert "工程目录已导入" in message and "已按目录数据试排一版" in message
|
|
assert any(row.get("orderNo") == "ORD1" for row in store.data.get("flexOrders") or [])
|
|
assert store.data.get("flexScheduleVersions")
|
|
event = next(
|
|
row for row in reversed(store.data["auditEvents"])
|
|
if row.get("action") == "folder.schedule.import"
|
|
)
|
|
assert event["rationale"]["frozenPayload"] is True
|
|
assert any(ref.startswith("folder-payload:") for ref in event["evidenceRefs"])
|
|
|
|
|
|
def test_folder_schedule_manifest_and_payload_share_one_immutable_read(
|
|
folder_env, monkeypatch,
|
|
):
|
|
store, identity, approvals, _checkpoints, sql_path = folder_env
|
|
from server.aps_domain import folder_pack
|
|
|
|
original_analyze = folder_pack.analyze_work_dir
|
|
original_sql = sql_path.read_text(encoding="utf-8")
|
|
swapped_sql = original_sql.replace("'ORD1'", "'ORD2'")
|
|
|
|
def swap_during_analysis(*args, **kwargs):
|
|
assert kwargs.get("source_snapshots")
|
|
sql_path.write_text(swapped_sql, encoding="utf-8")
|
|
try:
|
|
return original_analyze(*args, **kwargs)
|
|
finally:
|
|
sql_path.write_text(original_sql, encoding="utf-8")
|
|
|
|
monkeypatch.setattr(folder_pack, "analyze_work_dir", swap_during_analysis)
|
|
confirm_id, _reply = _stage(store, identity)
|
|
frozen_orders = approvals.pending[confirm_id]["params"]["sqlPayload"]["orders"]
|
|
assert [row["orderNo"] for row in frozen_orders] == ["ORD1"]
|
|
assert sql_path.read_text(encoding="utf-8") == original_sql
|
|
|
|
message = _approve(store, identity, confirm_id)
|
|
|
|
assert "已按目录数据试排一版" in message
|
|
assert any(row.get("orderNo") == "ORD1" for row in store.data.get("flexOrders") or [])
|
|
assert not any(row.get("orderNo") == "ORD2" for row in store.data.get("flexOrders") or [])
|