aps-agent/tests/golden/test_folder_schedule_securi...

351 lines
14 KiB
Python
Raw Permalink Normal View History

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 [block.type for block in reply.blocks] == ["confirm-card"]
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 [])