162 lines
5.3 KiB
Python
162 lines
5.3 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from govoplan_core.core.object_storage import LocalFilesystemStorageBackend
|
|
from govoplan_core.core.recovery import RecoveryMode
|
|
from govoplan_campaign.backend.persistence.campaigns import (
|
|
CampaignPersistenceError,
|
|
_StoredEmlArtifact,
|
|
_build_storage_expectations,
|
|
_delete_storage_keys,
|
|
_verify_build_storage_manifest,
|
|
_verify_storage_keys_absent,
|
|
)
|
|
from govoplan_campaign.backend.routes.versions import (
|
|
_attachment_reuse_audit_evidence,
|
|
_campaign_build_recovery_plan,
|
|
_residual_file_audit_evidence,
|
|
_review_decision_audit_evidence,
|
|
)
|
|
|
|
|
|
def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() -> None:
|
|
assert _campaign_build_recovery_plan({}).mode == RecoveryMode.COMPENSATION
|
|
assert (
|
|
_campaign_build_recovery_plan(
|
|
{"delivery": {"print": {"persist_to_files": True}}}
|
|
).mode
|
|
== RecoveryMode.FORWARD_RECOVERY
|
|
)
|
|
assert (
|
|
_campaign_build_recovery_plan(
|
|
{"delivery": {"print": {"persist_to_files": False}}}
|
|
).mode
|
|
== RecoveryMode.COMPENSATION
|
|
)
|
|
|
|
|
|
def test_residual_file_audit_evidence_omits_recipient_identity() -> None:
|
|
assert _residual_file_audit_evidence({
|
|
"contract_version": "1",
|
|
"action": "route_report",
|
|
"routing_mode": "report",
|
|
"validation_behavior": "warn",
|
|
"watched_source_count": 2,
|
|
"residual_file_count": 3,
|
|
"recipient": {"email": "operator@example.org"},
|
|
}) == {
|
|
"contract_version": "1",
|
|
"action": "route_report",
|
|
"routing_mode": "report",
|
|
"validation_behavior": "warn",
|
|
"watched_source_count": 2,
|
|
"residual_file_count": 3,
|
|
}
|
|
|
|
|
|
def test_attachment_reuse_audit_evidence_keeps_policy_but_not_file_names() -> None:
|
|
assert _attachment_reuse_audit_evidence({
|
|
"contract_version": "1",
|
|
"policy": {"action": "review", "allow_within": "same_recipient"},
|
|
"duplicate_file_count": 4,
|
|
"allowed_file_count": 1,
|
|
"violation_file_count": 3,
|
|
"affected_message_count": 5,
|
|
"findings": [{"file_name": "personal.pdf"}],
|
|
}) == {
|
|
"contract_version": "1",
|
|
"policy": {"action": "review", "allow_within": "same_recipient"},
|
|
"duplicate_file_count": 4,
|
|
"allowed_file_count": 1,
|
|
"violation_file_count": 3,
|
|
"affected_message_count": 5,
|
|
}
|
|
|
|
|
|
def test_review_decision_audit_evidence_is_aggregate_and_integrity_sealed() -> None:
|
|
version = type("Version", (), {
|
|
"editor_state": {
|
|
"review_send": {
|
|
"issue_decisions": [{
|
|
"decision": "accept",
|
|
"reason": "Approved exception",
|
|
"issue_codes": ["duplicate_attachment_reuse"],
|
|
"issue_fingerprint": "f" * 64,
|
|
"message_sha256": "m" * 64,
|
|
}]
|
|
}
|
|
}
|
|
})()
|
|
|
|
evidence = _review_decision_audit_evidence(version)
|
|
|
|
assert evidence["count"] == 1
|
|
assert evidence["with_reason_count"] == 1
|
|
assert evidence["issue_codes"] == ["duplicate_attachment_reuse"]
|
|
assert len(str(evidence["evidence_sha256"])) == 64
|
|
assert "Approved exception" not in str(evidence)
|
|
|
|
|
|
def test_generated_object_manifest_verifies_exact_bytes(tmp_path: Path) -> None:
|
|
storage = LocalFilesystemStorageBackend(tmp_path)
|
|
payload = b"Message-ID: <build@example.test>\r\n\r\nbody"
|
|
key = "campaign-artifacts/tenant/campaign/version/build/00000001.eml"
|
|
storage.put_bytes(key, payload)
|
|
artifact = _StoredEmlArtifact(
|
|
storage_key=key,
|
|
size_bytes=len(payload),
|
|
sha256=hashlib.sha256(payload).hexdigest(),
|
|
message_id_header="<build@example.test>",
|
|
)
|
|
|
|
evidence = _verify_build_storage_manifest(
|
|
storage,
|
|
_build_storage_expectations(
|
|
stored_eml_by_index={1: artifact},
|
|
print_outputs_by_index={},
|
|
),
|
|
)
|
|
|
|
assert evidence["object_count"] == 1
|
|
assert evidence["total_bytes"] == len(payload)
|
|
storage.put_bytes(key, b"tampered")
|
|
with pytest.raises(CampaignPersistenceError, match="does not match"):
|
|
_verify_build_storage_manifest(
|
|
storage,
|
|
_build_storage_expectations(
|
|
stored_eml_by_index={1: artifact},
|
|
print_outputs_by_index={},
|
|
),
|
|
)
|
|
|
|
|
|
def test_compensation_requires_verified_object_absence(tmp_path: Path) -> None:
|
|
storage = LocalFilesystemStorageBackend(tmp_path)
|
|
key = "campaign-artifacts/tenant/campaign/version/build/object"
|
|
storage.put_bytes(key, b"payload")
|
|
assert _delete_storage_keys(storage, [key]) == []
|
|
assert _verify_storage_keys_absent(storage, [key]) == (True, 0)
|
|
|
|
|
|
class _UnremovableStorage:
|
|
name = "unremovable"
|
|
|
|
def delete(self, _key: str) -> None:
|
|
from govoplan_core.core.object_storage import StorageBackendError
|
|
|
|
raise StorageBackendError("unavailable")
|
|
|
|
def exists(self, _key: str) -> bool:
|
|
return True
|
|
|
|
|
|
def test_failed_compensation_remains_observable() -> None:
|
|
storage = _UnremovableStorage()
|
|
key = "campaign-artifacts/tenant/campaign/version/build/object"
|
|
assert _delete_storage_keys(storage, [key]) == [key] # type: ignore[arg-type]
|
|
assert _verify_storage_keys_absent(storage, [key]) == (False, 1) # type: ignore[arg-type]
|