test_governance_audit.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. from __future__ import annotations
  2. from copy import deepcopy
  3. from datetime import UTC, datetime, timedelta
  4. import pytest
  5. class FakeAuditRepository:
  6. def __init__(self, events=None):
  7. self.events = list(events or [])
  8. self.seals = {}
  9. def fetch_events(self, *, categories, period_start, period_end):
  10. return [
  11. deepcopy(event)
  12. for event in self.events
  13. if event["category"] in categories
  14. and period_start <= event["occurred_at"] <= period_end
  15. ]
  16. def save_seal(self, seal):
  17. self.seals[seal["uid"]] = deepcopy(seal)
  18. return deepcopy(seal)
  19. def get_seal(self, seal_uid):
  20. seal = self.seals.get(seal_uid)
  21. return deepcopy(seal) if seal else None
  22. def list_seals(self, *, limit):
  23. return list(self.seals.values())[:limit]
  24. def _event(
  25. uid,
  26. category,
  27. *,
  28. occurred_at,
  29. action="completed",
  30. status="success",
  31. detail=None,
  32. ):
  33. return {
  34. "event_uid": uid,
  35. "category": category,
  36. "action": action,
  37. "status": status,
  38. "actor_uid": "actor-1",
  39. "resource_type": "device_asset",
  40. "resource_uid": "asset-1",
  41. "occurred_at": occurred_at,
  42. "safe_detail": detail or {"attempt": 1},
  43. }
  44. def _service(events):
  45. from app.core.system.governance_audit import GovernanceAuditService
  46. return GovernanceAuditService(
  47. FakeAuditRepository(events),
  48. evidence_secret="wp12-test-secret-with-at-least-32-bytes",
  49. key_version="test-v1",
  50. uid_factory=lambda: "seal-1",
  51. now_factory=lambda: datetime(2026, 7, 31, tzinfo=UTC),
  52. )
  53. def test_list_events_is_deterministic_bounded_and_secret_safe():
  54. from app.core.system.governance_audit import GovernanceAuditInvalid
  55. now = datetime(2026, 7, 30, 8, 0, tzinfo=UTC)
  56. events = [
  57. _event(
  58. "event-b",
  59. "ingestion",
  60. occurred_at=now,
  61. detail={
  62. "attempt": 2,
  63. "password": "must-not-leak",
  64. "nested": {"authorization": "Bearer secret"},
  65. },
  66. ),
  67. _event("event-a", "authentication", occurred_at=now),
  68. ]
  69. service = _service(events)
  70. result = service.list_events(
  71. period_start=now - timedelta(days=1),
  72. period_end=now + timedelta(days=1),
  73. page=1,
  74. page_size=1,
  75. )
  76. assert result["total"] == 2
  77. assert result["records"][0]["event_uid"] == "event-b"
  78. assert result["records"][0]["safe_detail"] == {
  79. "attempt": 2,
  80. "password": "[redacted]",
  81. "nested": {"authorization": "[redacted]"},
  82. }
  83. assert "must-not-leak" not in repr(result)
  84. assert "Bearer secret" not in repr(result)
  85. with pytest.raises(GovernanceAuditInvalid, match="page_size"):
  86. service.list_events(
  87. period_start=now - timedelta(days=1),
  88. period_end=now,
  89. page_size=101,
  90. )
  91. with pytest.raises(GovernanceAuditInvalid, match="category"):
  92. service.list_events(
  93. period_start=now - timedelta(days=1),
  94. period_end=now,
  95. categories=["arbitrary"],
  96. )
  97. def test_coverage_always_returns_the_six_fixed_categories():
  98. from app.core.system.governance_audit import AUDIT_CATEGORIES
  99. now = datetime(2026, 7, 30, 8, 0, tzinfo=UTC)
  100. service = _service(
  101. [_event("event-1", "authentication", occurred_at=now)]
  102. )
  103. coverage = service.coverage(
  104. period_start=now - timedelta(days=1),
  105. period_end=now + timedelta(seconds=1),
  106. )
  107. assert [item["category"] for item in coverage["categories"]] == list(
  108. AUDIT_CATEGORIES
  109. )
  110. assert coverage["categories"][0]["count"] == 1
  111. assert all(
  112. item["count"] == 0 for item in coverage["categories"][1:]
  113. )
  114. def test_signed_seal_is_stable_and_detects_changed_or_missing_events():
  115. now = datetime(2026, 7, 30, 8, 0, tzinfo=UTC)
  116. events = [
  117. _event("event-1", "authentication", occurred_at=now),
  118. _event("event-2", "ingestion", occurred_at=now + timedelta(seconds=1)),
  119. ]
  120. repository = FakeAuditRepository(events)
  121. from app.core.system.governance_audit import GovernanceAuditService
  122. service = GovernanceAuditService(
  123. repository,
  124. evidence_secret="wp12-test-secret-with-at-least-32-bytes",
  125. key_version="test-v1",
  126. uid_factory=lambda: "seal-1",
  127. now_factory=lambda: now + timedelta(days=1),
  128. )
  129. seal = service.create_seal(
  130. period_start=now - timedelta(seconds=1),
  131. period_end=now + timedelta(seconds=2),
  132. categories=["authentication", "ingestion"],
  133. actor_uid="admin-1",
  134. )
  135. assert seal["event_count"] == 2
  136. assert len(seal["root_hash"]) == 64
  137. assert len(seal["signature"]) == 64
  138. assert service.verify_seal("seal-1")["integrity_status"] == "intact"
  139. repository.events[1]["status"] = "failed"
  140. changed = service.verify_seal("seal-1")
  141. assert changed["integrity_status"] == "tampered"
  142. assert changed["actual_event_count"] == 2
  143. assert changed["actual_root_hash"] != seal["root_hash"]
  144. repository.events.pop()
  145. missing = service.verify_seal("seal-1")
  146. assert missing["integrity_status"] == "tampered"
  147. assert missing["actual_event_count"] == 1
  148. def test_seal_signature_rejects_modified_metadata_and_weak_secret():
  149. from app.core.system.governance_audit import (
  150. GovernanceAuditInvalid,
  151. GovernanceAuditService,
  152. )
  153. now = datetime(2026, 7, 30, 8, 0, tzinfo=UTC)
  154. repository = FakeAuditRepository(
  155. [_event("event-1", "authentication", occurred_at=now)]
  156. )
  157. service = GovernanceAuditService(
  158. repository,
  159. evidence_secret="wp12-test-secret-with-at-least-32-bytes",
  160. key_version="test-v1",
  161. uid_factory=lambda: "seal-1",
  162. now_factory=lambda: now + timedelta(days=1),
  163. )
  164. service.create_seal(
  165. period_start=now - timedelta(seconds=1),
  166. period_end=now + timedelta(seconds=1),
  167. actor_uid="admin-1",
  168. )
  169. repository.seals["seal-1"]["event_count"] = 99
  170. assert (
  171. service.verify_seal("seal-1")["integrity_status"]
  172. == "invalid_signature"
  173. )
  174. with pytest.raises(GovernanceAuditInvalid, match="32"):
  175. GovernanceAuditService(
  176. repository,
  177. evidence_secret="short",
  178. key_version="test-v1",
  179. )
  180. def test_seal_rejects_invalid_windows_and_unbounded_event_sets():
  181. from app.core.system.governance_audit import (
  182. GovernanceAuditInvalid,
  183. GovernanceAuditService,
  184. )
  185. now = datetime(2026, 7, 30, 8, 0, tzinfo=UTC)
  186. service = _service([])
  187. with pytest.raises(GovernanceAuditInvalid, match="period_end"):
  188. service.create_seal(
  189. period_start=now,
  190. period_end=now,
  191. actor_uid="admin-1",
  192. )
  193. class OversizedRepository(FakeAuditRepository):
  194. def fetch_events(self, **_kwargs):
  195. event = _event(
  196. "event-1", "authentication", occurred_at=now
  197. )
  198. return [event] * 50_001
  199. oversized = GovernanceAuditService(
  200. OversizedRepository(),
  201. evidence_secret="wp12-test-secret-with-at-least-32-bytes",
  202. key_version="test-v1",
  203. now_factory=lambda: now + timedelta(days=1),
  204. )
  205. with pytest.raises(GovernanceAuditInvalid, match="50,000"):
  206. oversized.create_seal(
  207. period_start=now - timedelta(seconds=1),
  208. period_end=now + timedelta(seconds=1),
  209. actor_uid="admin-1",
  210. )
  211. def test_seal_rejects_a_window_that_ends_in_the_future():
  212. from app.core.system.governance_audit import (
  213. GovernanceAuditInvalid,
  214. GovernanceAuditService,
  215. )
  216. now = datetime(2026, 7, 30, 8, 0, tzinfo=UTC)
  217. service = GovernanceAuditService(
  218. FakeAuditRepository(),
  219. evidence_secret="wp12-test-secret-with-at-least-32-bytes",
  220. key_version="test-v1",
  221. now_factory=lambda: now,
  222. )
  223. with pytest.raises(GovernanceAuditInvalid, match="future"):
  224. service.create_seal(
  225. period_start=now - timedelta(days=1),
  226. period_end=now + timedelta(seconds=1),
  227. actor_uid="admin-1",
  228. )