test_rules.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. from __future__ import annotations
  2. import copy
  3. import hashlib
  4. import json
  5. import pytest
  6. from app.core.common.identifiers import new_governance_uid
  7. from app.runner.nodes import NodeExecutionError
  8. PLAN = {"op": "not_null", "column": "mobile"}
  9. PLAN_HASH = hashlib.sha256(
  10. json.dumps(
  11. PLAN,
  12. sort_keys=True,
  13. separators=(",", ":"),
  14. ensure_ascii=False,
  15. ).encode("utf-8")
  16. ).hexdigest()
  17. def rule_node(node_type="quality.check"):
  18. node = {
  19. "id": "customer_mobile",
  20. "type": node_type,
  21. "purpose": "read" if node_type == "quality.check" else "write",
  22. "config": {
  23. "component_binding_id": new_governance_uid(),
  24. "rule_version_id": new_governance_uid(),
  25. "execution_plan_hash": PLAN_HASH,
  26. },
  27. }
  28. if node_type == "rule.apply":
  29. node["idempotency"] = {
  30. "strategy": "upsert",
  31. "key": "customer_id",
  32. }
  33. return node
  34. class Repository:
  35. def __init__(self, record=None):
  36. self.record = record
  37. self.calls = []
  38. def load(self, **kwargs):
  39. self.calls.append(kwargs)
  40. return copy.deepcopy(self.record)
  41. class Adapter:
  42. def __init__(self):
  43. self.calls = []
  44. def execute(self, *, plan, node, parameters, write_authorized):
  45. self.calls.append(
  46. {
  47. "plan": plan,
  48. "node": node,
  49. "parameters": parameters,
  50. "write_authorized": write_authorized,
  51. }
  52. )
  53. return {"rows_rejected": 3}
  54. def published_record(node, **overrides):
  55. value = {
  56. "component_binding_id": node["config"]["component_binding_id"],
  57. "rule_version_id": node["config"]["rule_version_id"],
  58. "backend": "quality_check",
  59. "plan": PLAN,
  60. "plan_hash": PLAN_HASH,
  61. "plan_status": "published",
  62. "rule_status": "published",
  63. "component_kind": node["type"],
  64. "binding_idempotency": node.get("idempotency"),
  65. }
  66. value.update(overrides)
  67. return value
  68. def test_rule_executor_loads_only_published_plan_by_fixed_identifiers():
  69. from app.runner.rules import RulePlanExecutor
  70. node = rule_node()
  71. adapter = Adapter()
  72. repository = Repository(published_record(node))
  73. executor = RulePlanExecutor(
  74. repository,
  75. adapters={"quality_check": adapter},
  76. )
  77. result = executor.execute(node, {"partition": "2026-07-23"})
  78. assert result["rows_rejected"] == 3
  79. assert result["rule_version_id"] == node["config"]["rule_version_id"]
  80. assert repository.calls == [
  81. {
  82. "component_binding_id": node["config"]["component_binding_id"],
  83. "rule_version_id": node["config"]["rule_version_id"],
  84. "plan_hash": PLAN_HASH,
  85. }
  86. ]
  87. assert adapter.calls[0]["parameters"] == {"partition": "2026-07-23"}
  88. @pytest.mark.parametrize(
  89. "record",
  90. [
  91. None,
  92. {"plan_status": "compiled"},
  93. {"plan_status": "tested"},
  94. {"plan_status": "revoked"},
  95. {"rule_status": "deprecated"},
  96. {"plan_hash": "b" * 64},
  97. ],
  98. )
  99. def test_rule_executor_fails_closed_for_missing_revoked_or_mismatched_plan(record):
  100. from app.runner.rules import RulePlanExecutor
  101. node = rule_node()
  102. base = published_record(node)
  103. if record is not None:
  104. base.update(record)
  105. record = base
  106. executor = RulePlanExecutor(
  107. Repository(record),
  108. adapters={"quality_check": Adapter()},
  109. )
  110. with pytest.raises(NodeExecutionError):
  111. executor.execute(node, {})
  112. def test_rule_executor_rejects_inline_plan_or_unregistered_backend():
  113. from app.runner.rules import RulePlanExecutor
  114. node = rule_node()
  115. node["config"]["plan"] = {"op": "bypass"}
  116. executor = RulePlanExecutor(
  117. Repository(published_record(node)),
  118. adapters={},
  119. )
  120. with pytest.raises(NodeExecutionError):
  121. executor.execute(node, {})
  122. clean = rule_node()
  123. with pytest.raises(NodeExecutionError):
  124. RulePlanExecutor(
  125. Repository(published_record(clean, backend="generated_python")),
  126. adapters={},
  127. ).execute(clean, {})
  128. def test_mutating_rule_requires_governed_write_authorization_and_idempotency():
  129. from app.runner.rules import RulePlanExecutor
  130. node = rule_node("rule.apply")
  131. record = published_record(node, backend="sql_pushdown")
  132. executor = RulePlanExecutor(
  133. Repository(record),
  134. adapters={"sql_pushdown": Adapter()},
  135. )
  136. with pytest.raises(NodeExecutionError):
  137. executor.execute(node, {}, write_authorized=False)
  138. del node["idempotency"]
  139. with pytest.raises(NodeExecutionError):
  140. executor.execute(node, {}, write_authorized=True)