115 lines
5 KiB
Python
115 lines
5 KiB
Python
import copy
|
|
import hashlib
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from scripts.prepare_m3_review_packet import ROOT, create_packet
|
|
|
|
|
|
TEMPLATE = ROOT / "tests" / "fixtures" / "m3-hardware-protocol-template.json"
|
|
|
|
|
|
def write_ready_plan(root: Path) -> Path:
|
|
plan = copy.deepcopy(json.loads(TEMPLATE.read_text(encoding="utf-8")))
|
|
plan["roles"] = {
|
|
"operator": "Synthetic Operator",
|
|
"safety_observer": "Synthetic Observer",
|
|
"protocol_reviewer": "Synthetic Reviewer",
|
|
}
|
|
for key in plan["physical_controls"]:
|
|
plan["physical_controls"][key] = True
|
|
plan["physical_controls"]["emission_inhibition_method"] = "Synthetic method"
|
|
plan["software_controls"]["operation_allowlist_enforced"] = True
|
|
plan["software_controls"]["reversible_driver_plan_reviewed"] = True
|
|
plan["allowed_operations"] = [{
|
|
"operation_id": "synthetic-read-identity",
|
|
"purpose": "Synthetic packet test only",
|
|
"evidence_artifact_id": "sha256-0123456789abcdef0123",
|
|
"evidence_level": "independently-implemented",
|
|
"safety_class": "read-only-no-motion-no-emission",
|
|
"direction": "host-to-controller-request-response",
|
|
"request_hex": "0102",
|
|
"expected_response": "Synthetic fixed response contract",
|
|
"timeout_ms": 1000,
|
|
"maximum_attempts": 1,
|
|
"state_change_expected": False,
|
|
}]
|
|
plan["recovery"].update({
|
|
"power_down_procedure_reviewed": True,
|
|
"driver_rollback_procedure_reviewed": True,
|
|
"incident_record_destination_prepared": True,
|
|
})
|
|
plan["session"] = {
|
|
"machine_location": "Synthetic location",
|
|
"planned_at": "2026-10-21T12:00:00+02:00",
|
|
"ends_at": "2026-10-21T12:30:00+02:00",
|
|
"notes": "Synthetic unit test only",
|
|
}
|
|
path = root / "source-plan.json"
|
|
path.write_text(json.dumps(plan), encoding="utf-8")
|
|
return path
|
|
|
|
|
|
class M3ReviewPacketTests(unittest.TestCase):
|
|
|
|
def test_packet_freezes_exact_plan_and_stays_non_authorizing(self):
|
|
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
|
root = Path(directory)
|
|
source = write_ready_plan(root)
|
|
source_bytes = source.read_bytes()
|
|
output = root / "packet"
|
|
manifest = create_packet(source, output, repository_commit="a" * 40)
|
|
review = json.loads((output / "approval-record-template.json").read_text())
|
|
frozen_bytes = (output / "plan.json").read_bytes()
|
|
self.assertEqual(source_bytes, frozen_bytes)
|
|
self.assertEqual(hashlib.sha256(source_bytes).hexdigest(), review["binding"]["plan_sha256"])
|
|
self.assertEqual("not_reviewed", review["decision"])
|
|
self.assertFalse(review["hardware_access_authorized"])
|
|
self.assertFalse(manifest["hardware_access_authorized"])
|
|
self.assertEqual("a" * 40, manifest["repository_commit"])
|
|
|
|
def test_incomplete_public_template_is_rejected(self):
|
|
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
|
with self.assertRaisesRegex(ValueError, "not ready for review"):
|
|
create_packet(TEMPLATE, Path(directory) / "packet", "b" * 40)
|
|
|
|
def test_changed_plan_changes_binding(self):
|
|
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
|
root = Path(directory)
|
|
source = write_ready_plan(root)
|
|
create_packet(source, root / "one", "c" * 40)
|
|
one = json.loads((root / "one" / "approval-record-template.json").read_text())
|
|
plan = json.loads(source.read_text())
|
|
plan["session"]["notes"] = "Changed synthetic note"
|
|
source.write_text(json.dumps(plan), encoding="utf-8")
|
|
create_packet(source, root / "two", "c" * 40)
|
|
two = json.loads((root / "two" / "approval-record-template.json").read_text())
|
|
self.assertNotEqual(one["binding"]["plan_sha256"], two["binding"]["plan_sha256"])
|
|
|
|
def test_nonempty_output_is_not_overwritten(self):
|
|
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
|
root = Path(directory)
|
|
source = write_ready_plan(root)
|
|
output = root / "packet"
|
|
output.mkdir()
|
|
sentinel = output / "keep.txt"
|
|
sentinel.write_text("keep", encoding="utf-8")
|
|
with self.assertRaisesRegex(ValueError, "not empty"):
|
|
create_packet(source, output, "d" * 40)
|
|
self.assertEqual("keep", sentinel.read_text(encoding="utf-8"))
|
|
|
|
def test_manifest_hashes_every_packet_file(self):
|
|
with tempfile.TemporaryDirectory(dir=ROOT / ".build") as directory:
|
|
root = Path(directory)
|
|
output = root / "packet"
|
|
manifest = create_packet(write_ready_plan(root), output, "e" * 40)
|
|
for record in manifest["files"]:
|
|
path = output / record["path"]
|
|
self.assertEqual(path.stat().st_size, record["bytes"])
|
|
self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), record["sha256"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|