aps-agent/tests/golden/test_rebuild_pipeline.py

286 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 可重生算法流水线黄金测试(矩阵 91,方向 T):
# T1 候选构建 → 隔离黄金通过 → shadow 灰度 → 灰度转正(promote)
# T2 黄金失败 → 自动回滚(注册表恢复 + 报告)
# T3 未登记 moduleId → 显式失败
# T4 灰度期失败(shadow 探针)→ 自动回退到上一可用注册版本
# T5 审计事件齐全(started/passed/failed/gray/rolled_back)且链完整
# T6 安全扫描:缺可重生注释 → 阻断;危险原语 / 未登记写世界 → 告警
# T7 网关 /api/rebuild/* 端点接线(404 显式失败 / 状态列表)
# 隔离:假 moduleId 通过 source_path 注入(不跑真实全部黄金,避免慢);
# 隔离运行只跑最小黄金子集,APS_* 指向随机临时目录。
# ============================================================
from __future__ import annotations
from pathlib import Path
import pytest
from server.agent_core.registry import (
active_version,
baseline_version,
get_rebuild_candidate,
verify_audit_chain,
)
from server.agent_core.rebuild_orchestrator import (
RebuildLookupError,
RebuildOrchestrator,
run_isolated_golden,
static_safety_scan,
)
ROOT = Path(__file__).resolve().parents[2]
MODULE_ID = "fixture-rebuild-demo"
class _Store:
"""最小可审计 store 替身(同 test_audit_anchor_api 模式,隔离生产 world)。"""
def __init__(self) -> None:
self.tenant_uuid = "platform"
self.world_key = "default"
self.data: dict = {}
self._ids: dict[str, int] = {}
def next_id(self, kind: str) -> int:
self._ids[kind] = self._ids.get(kind, 0) + 1
return self._ids[kind]
def save(self) -> None:
pass
@pytest.fixture()
def fixture_module(tmp_path):
"""自带可重生头注释的假模块 + 最小黄金子集(隔离环境自检 + 冒烟)。"""
src = tmp_path / "fixture_rebuild_demo.py"
src.write_text(
"# ============================================================\n"
"# 测试用可重生模块(moduleId: fixture-rebuild-demo, 可重生 ✅)\n"
"# ============================================================\n"
"from __future__ import annotations\n\n"
"def demo_value() -> int:\n"
" return 42\n",
encoding="utf-8",
)
golden = tmp_path / "test_fixture_rebuild_demo.py"
src_dir = str(src.parent).replace("\\", "\\\\")
golden.write_text(
"import os\n"
"import sys\n"
"import tempfile\n\n"
"def test_isolated_env() -> None:\n"
" wp = os.environ.get('APS_WORLD_PATH', '')\n"
" assert 'aps-rebuild-' in wp, wp\n"
" assert os.environ.get('APS_DB_PATH', '')\n"
" assert os.environ.get('APS_KNOWLEDGE_PATH', '')\n\n"
"def test_candidate_smoke() -> None:\n"
f" sys.path.insert(0, r'{src_dir}')\n"
" from fixture_rebuild_demo import demo_value\n"
" assert demo_value() == 42\n",
encoding="utf-8",
)
return {"moduleId": MODULE_ID, "source": str(src), "golden": str(golden)}
def _rebuild_actions(store) -> list[str]:
return [ev.get("action") for ev in store.data.get("auditEvents", [])]
# ---------------- T1:候选构建 → 隔离黄金通过 → 灰度 → 转正 ----------------
def test_rebuild_full_flow_promote(fixture_module):
store = _Store()
orch = RebuildOrchestrator(store, repo_root=str(ROOT))
report = orch.start_rebuild(MODULE_ID, golden_tests=[fixture_module["golden"]],
source_path=fixture_module["source"], actor="tester")
assert report["status"] == "GRAY", report
assert report["golden"]["passed"] is True
assert report["golden"]["elapsedSec"] >= 0
assert report["safety"]["ok"] is True
# 构建包:源文件清单 + 依赖 + 黄金测试映射
assert report["package"]["sourceFiles"] == [fixture_module["source"].replace("\\", "/")]
assert report["package"]["goldenTests"] == [fixture_module["golden"]]
# shadow 灰度:候选与现行并存(激活指针不动,基线 current)
assert active_version(store.data, MODULE_ID) is None
assert baseline_version(store.data, MODULE_ID) == "current"
cand = get_rebuild_candidate(store.data, MODULE_ID)
assert cand["status"] == "GRAY"
assert cand["version"] == f"{MODULE_ID}-v1"
actions = _rebuild_actions(store)
assert "rebuild.started" in actions and "rebuild.passed" in actions
# 灰度转正
promoted = orch.promote(MODULE_ID, actor="tester")
assert promoted["status"] == "ACTIVE"
assert active_version(store.data, MODULE_ID) == cand["version"]
assert "rebuild.gray" in _rebuild_actions(store)
assert verify_audit_chain(store.data["auditEvents"])["ok"] is True
# ---------------- T2:黄金失败 → 自动回滚 ----------------
def test_rebuild_golden_failure_auto_rollback(fixture_module):
bad = Path(fixture_module["golden"]).parent / "test_fails.py"
bad.write_text("def test_must_fail() -> None:\n assert 1 == 2\n", encoding="utf-8")
store = _Store()
orch = RebuildOrchestrator(store, repo_root=str(ROOT))
report = orch.start_rebuild(MODULE_ID, golden_tests=[str(bad)],
source_path=fixture_module["source"], actor="tester")
assert report["status"] == "FAILED", report
assert report["golden"]["passed"] is False
assert report["rollback"]["restoredTo"] == "current"
cand = get_rebuild_candidate(store.data, MODULE_ID)
assert cand["status"] == "FAILED"
assert cand["autoRolledBack"] is True
actions = _rebuild_actions(store)
assert "rebuild.failed" in actions and "rebuild.rolled_back" in actions
assert verify_audit_chain(store.data["auditEvents"])["ok"] is True
# ---------------- T3:未登记 moduleId → 显式失败 ----------------
def test_rebuild_unregistered_module_explicit_failure():
store = _Store()
orch = RebuildOrchestrator(store, repo_root=str(ROOT))
with pytest.raises(RebuildLookupError):
orch.start_rebuild("fixture-no-such-module-xyz", actor="tester")
# 注册表无候选、无审计(未启动流水线)
assert get_rebuild_candidate(store.data, "fixture-no-such-module-xyz") is None
assert store.data.get("auditEvents") is None
# ---------------- T4:灰度期失败 → 自动回退上一可用注册版本 ----------------
def test_rebuild_gray_failure_auto_rollback(fixture_module):
store = _Store()
orch = RebuildOrchestrator(store, repo_root=str(ROOT))
def bad_probe(report):
return {"ok": False, "reason": "shadow 流量异常"}
report = orch.start_rebuild(MODULE_ID, golden_tests=[fixture_module["golden"]],
source_path=fixture_module["source"],
gray_probe=bad_probe, actor="tester")
assert report["status"] == "ROLLED_BACK", report
assert report["rollback"]["restoredTo"] == "current"
cand = get_rebuild_candidate(store.data, MODULE_ID)
assert cand["status"] == "ROLLED_BACK"
actions = _rebuild_actions(store)
for expected in ("rebuild.started", "rebuild.passed", "rebuild.failed",
"rebuild.rolled_back"):
assert expected in actions, f"缺审计事件 {expected}"
assert verify_audit_chain(store.data["auditEvents"])["ok"] is True
# ---------------- T5:审计事件齐全 + 链完整 ----------------
def test_rebuild_audit_events_complete(fixture_module):
store = _Store()
orch = RebuildOrchestrator(store, repo_root=str(ROOT))
orch.start_rebuild(MODULE_ID, golden_tests=[fixture_module["golden"]],
source_path=fixture_module["source"], actor="tester")
orch.promote(MODULE_ID, actor="tester")
orch.rollback(MODULE_ID, reason="manual", actor="tester")
events = store.data["auditEvents"]
actions = [ev["action"] for ev in events]
for expected in ("rebuild.started", "rebuild.passed", "rebuild.gray",
"rebuild.rolled_back"):
assert expected in actions, f"缺审计事件 {expected}"
assert verify_audit_chain(events)["ok"] is True
for ev in events: # REBUILD 事件结构合法
assert ev["category"] == "REBUILD"
assert ev["target"]["type"] == "REBUILD"
assert ev["power"] == "P1"
# ---------------- T6:安全扫描(静态规则) ----------------
def test_rebuild_safety_scan_blocks_non_regenerable(tmp_path):
src = tmp_path / "fixture_no_regen.py"
src.write_text(
"# 普通模块(moduleId: fixture-no-regen,非再生模块)\n"
"def f() -> int:\n return 1\n",
encoding="utf-8",
)
store = _Store()
orch = RebuildOrchestrator(store, repo_root=str(ROOT))
report = orch.start_rebuild("fixture-no-regen", golden_tests=[str(tmp_path / "t.py")],
source_path=str(src), actor="tester")
assert report["status"] == "FAILED", report
assert report["safety"]["ok"] is False
assert any(f["rule"] == "S1" for f in report["safety"]["findings"])
assert "rebuild.failed" in _rebuild_actions(store)
def test_rebuild_safety_scan_warns_on_dangerous_primitives(tmp_path):
src = tmp_path / "fixture_danger.py"
src.write_text(
"# (moduleId: fixture-danger, 可重生 ✅)\n"
"def f(x):\n return eval(x)\n",
encoding="utf-8",
)
report = static_safety_scan({"moduleId": "fixture-danger", "path": str(src)},
repo_root=str(ROOT))
assert report["ok"] is True # 告警不阻断
assert "S2" in {f["rule"] for f in report["findings"]}
def test_rebuild_safety_scan_flags_unregistered_world_write(tmp_path):
src = tmp_path / "fixture_write.py"
src.write_text(
"# (moduleId: fixture-write, 可重生 ✅)\n"
"def f(world):\n world['orders'] = []\n",
encoding="utf-8",
)
report = static_safety_scan({"moduleId": "fixture-write", "path": str(src)},
repo_root=str(ROOT))
assert "S4" in {f["rule"] for f in report["findings"]}
# 经门禁/登记标记的写路径不算未登记
src2 = tmp_path / "fixture_write_gated.py"
src2.write_text(
"# (moduleId: fixture-write-gated, 可重生 ✅)\n"
"def f(world):\n"
" from server.agent_core import harness\n"
" harness.guard('order.upsert', {}, lambda: world.__setitem__('orders', []))\n",
encoding="utf-8",
)
report2 = static_safety_scan({"moduleId": "fixture-write-gated", "path": str(src2)},
repo_root=str(ROOT))
assert not any(f["rule"] == "S4" for f in report2["findings"])
# ---------------- 隔离运行:APS_* 指向临时目录 ----------------
def test_run_isolated_golden_uses_tmp_env(tmp_path):
probe = tmp_path / "test_probe_env.py"
probe.write_text(
"import os\n"
"def test_env_isolated() -> None:\n"
" wp = os.environ.get('APS_WORLD_PATH', '')\n"
" assert 'aps-rebuild-' in wp, wp\n"
" assert os.environ.get('APS_DB_PATH', '')\n",
encoding="utf-8",
)
result = run_isolated_golden([str(probe)], repo_root=str(ROOT))
assert result["passed"] is True, result
assert "aps-rebuild-" in result["env"]["APS_WORLD_PATH"]
# ---------------- T7:网关 /api/rebuild/* 接线 ----------------
def test_rebuild_gateway_endpoints_wired(monkeypatch):
import server.gateway.app as gateway_module
from fastapi.testclient import TestClient
from tests.auth_provider import install_test_auth
store = _Store()
monkeypatch.setattr(gateway_module, "get_store", lambda: store)
install_test_auth(monkeypatch, "tenant-rebuild-api")
client = TestClient(gateway_module.create_app())
login = client.post("/api/auth/login", json={"username": "planner", "password": "test"})
assert login.status_code == 200, login.text
# 未登记 moduleId → 404 显式失败(不触发子进程)
r = client.post("/api/rebuild/fixture-no-such-module-xyz")
assert r.status_code == 404, r.text
assert "未登记 moduleId" in r.json()["detail"]
# 状态列表:空流水线占位结构
g = client.get("/api/rebuild")
assert g.status_code == 200, g.text
body = g.json()
assert "candidates" in body and "activeVersions" in body and "baselines" in body
# 未登记模块的转正/回滚 → 404
assert client.post("/api/rebuild/fixture-no-such-module-xyz/promote").status_code == 404
assert client.post("/api/rebuild/fixture-no-such-module-xyz/rollback").status_code == 404