"""Tests for orchestrator/manifest.py — the canonical experiment manifest loader (PIPELINE.md §4.1).""" from __future__ import annotations from pathlib import Path import pytest from orchestrator.manifest import ( KNOWN_COLLECTORS, KNOWN_PHASES, ManifestError, load_canonical, ) REPO_ROOT = Path(__file__).resolve().parent.parent MINIMAL_VALID = """ schema_version = 1 name = "test-experiment" [experiment] ram_per_vm_mib = 320 [[experiment.schedule.phases]] name = "clean" seconds = 1.0 [[experiment.schedule.phases]] name = "armed" seconds = 1.0 [experiment.fleet] max_concurrent_ceiling = 0 max_tier3_slots = 0 [collectors] active = ["proc"] [collectors.intervals] proc_ms = 100 qmp_ms = 1000 perf_ms = 100 guest_agent_ms = 100 pcap_snaplen = 256 netflow_bucket_ms = 100 [catalog] modules = [] [targets] images = [] [samples] manifest_path = "samples/manifest.toml" """ def _write_manifest(repo: Path, body: str) -> None: (repo / "manifest.toml").write_text(body) def test_canonical_manifest_in_repo_loads() -> None: """The actual manifest.toml shipped in the repo MUST load and validate. If it doesn't, every lab host fails preflight.""" m = load_canonical(REPO_ROOT) assert m.schema_version == 1 assert m.name == "cis490-spectral-v1" # Every active collector must be in KNOWN_COLLECTORS. for c in m.collectors_active: assert c in KNOWN_COLLECTORS # Every scheduled phase name must be in KNOWN_PHASES. for p in m.schedule: assert p.name in KNOWN_PHASES def test_loads_minimal_valid(tmp_path: Path) -> None: _write_manifest(tmp_path, MINIMAL_VALID) m = load_canonical(tmp_path) assert m.name == "test-experiment" assert len(m.schedule) == 2 assert m.fleet.max_concurrent_ceiling == 0 assert m.collectors_active == ("proc",) def test_missing_file_raises_manifest_error(tmp_path: Path) -> None: with pytest.raises(ManifestError, match="not found"): load_canonical(tmp_path) def test_unsupported_schema_version_raises(tmp_path: Path) -> None: _write_manifest(tmp_path, MINIMAL_VALID.replace( "schema_version = 1", "schema_version = 2" )) with pytest.raises(ManifestError, match="schema_version=2"): load_canonical(tmp_path) def test_unknown_collector_in_active_raises(tmp_path: Path) -> None: _write_manifest(tmp_path, MINIMAL_VALID.replace( 'active = ["proc"]', 'active = ["proc", "totally_not_real"]' )) with pytest.raises(ManifestError, match="totally_not_real"): load_canonical(tmp_path) def test_duplicate_collector_in_active_raises(tmp_path: Path) -> None: _write_manifest(tmp_path, MINIMAL_VALID.replace( 'active = ["proc"]', 'active = ["proc", "proc"]' )) with pytest.raises(ManifestError, match="duplicate"): load_canonical(tmp_path) def test_unknown_phase_name_raises(tmp_path: Path) -> None: bad = MINIMAL_VALID.replace('name = "armed"', 'name = "magical"', 1) _write_manifest(tmp_path, bad) with pytest.raises(ManifestError, match="magical"): load_canonical(tmp_path) def test_negative_phase_seconds_raises(tmp_path: Path) -> None: bad = MINIMAL_VALID.replace('seconds = 1.0\n\n[[experiment.schedule.phases]]\nname = "armed"\nseconds = 1.0', 'seconds = 1.0\n\n[[experiment.schedule.phases]]\nname = "armed"\nseconds = -5.0') _write_manifest(tmp_path, bad) with pytest.raises(ManifestError, match="must be > 0"): load_canonical(tmp_path) def test_negative_ram_raises(tmp_path: Path) -> None: bad = MINIMAL_VALID.replace("ram_per_vm_mib = 320", "ram_per_vm_mib = -1") _write_manifest(tmp_path, bad) with pytest.raises(ManifestError, match="positive"): load_canonical(tmp_path) def test_catalog_entry_missing_verified_against_raises(tmp_path: Path) -> None: bad = MINIMAL_VALID.replace( "[catalog]\nmodules = []", '[catalog]\n[[catalog.modules]]\nname = "fixture"\nlast_verified = "abc"\n', ) _write_manifest(tmp_path, bad) with pytest.raises(ManifestError, match="verified_against"): load_canonical(tmp_path) def test_catalog_entry_with_both_fields_loads(tmp_path: Path) -> None: valid = MINIMAL_VALID.replace( "[catalog]\nmodules = []", ('[catalog]\n[[catalog.modules]]\nname = "fixture"\n' 'verified_against = "test-target"\nlast_verified = "abc"\n'), ) _write_manifest(tmp_path, valid) m = load_canonical(tmp_path) assert len(m.catalog) == 1 assert m.catalog[0].name == "fixture" assert m.catalog[0].verified_against == "test-target" assert m.catalog[0].last_verified == "abc" def test_to_meta_round_trips_to_json_safe(tmp_path: Path) -> None: """meta.json embedding requires the to_meta dict be json-encodable.""" import json _write_manifest(tmp_path, MINIMAL_VALID) m = load_canonical(tmp_path) encoded = json.dumps(m.to_meta()) decoded = json.loads(encoded) assert decoded["schema_version"] == 1 assert decoded["name"] == "test-experiment"