test_nodes.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. import json
  2. import pytest
  3. from app.runner.nodes import (
  4. GovernedHttpExecutor,
  5. NodeExecutionError,
  6. NodeRegistry,
  7. RestrictedPythonExecutor,
  8. SqlExecuteExecutor,
  9. SqlQueryExecutor,
  10. )
  11. class Rows:
  12. def __init__(self, rows=(), rowcount=0):
  13. self._rows = rows
  14. self.rowcount = rowcount
  15. def mappings(self):
  16. return self
  17. def all(self):
  18. return list(self._rows)
  19. class Connection:
  20. def __init__(self, result):
  21. self.result = result
  22. self.calls = []
  23. def execute(self, statement, parameters):
  24. self.calls.append((str(statement), parameters))
  25. return self.result
  26. class Manager:
  27. def __init__(self, result):
  28. self.connection = Connection(result)
  29. self.purposes = []
  30. def connect(self, uid, purpose):
  31. manager = self
  32. class Context:
  33. def __enter__(self):
  34. manager.purposes.append((uid, purpose))
  35. return manager.connection
  36. def __exit__(self, *_args):
  37. return False
  38. return Context()
  39. def test_sql_query_is_parameterized_read_only_and_bounded():
  40. manager = Manager(Rows([{"id": 1}, {"id": 2}]))
  41. executor = SqlQueryExecutor(manager, max_rows=1)
  42. node = {
  43. "id": "read",
  44. "type": "sql.query",
  45. "data_source_uid": "source-1",
  46. "purpose": "read",
  47. "config": {
  48. "statement": "SELECT id FROM orders WHERE day = :day",
  49. "parameters": {"day": "2026-07-19"},
  50. },
  51. }
  52. result = executor.execute(node, {})
  53. assert result == {"rows": [{"id": 1}], "row_count": 1, "truncated": True}
  54. assert manager.purposes == [("source-1", "dataflow_read")]
  55. assert manager.connection.calls == [
  56. ("SELECT id FROM orders WHERE day = :day", {"day": "2026-07-19"})
  57. ]
  58. def test_sql_parameters_resolve_only_declared_runtime_placeholders():
  59. manager = Manager(Rows([{"id": 1}]))
  60. executor = SqlQueryExecutor(manager)
  61. node = {
  62. "id": "read",
  63. "type": "sql.query",
  64. "data_source_uid": "source-1",
  65. "purpose": "read",
  66. "config": {
  67. "statement": "SELECT id FROM orders WHERE day = :day",
  68. "parameters": {"day": "${parameters.day}"},
  69. },
  70. }
  71. executor.execute(node, {"day": "2026-07-19"})
  72. assert manager.connection.calls[-1][1] == {"day": "2026-07-19"}
  73. with pytest.raises(NodeExecutionError, match="runtime parameter"):
  74. executor.execute(node, {})
  75. @pytest.mark.parametrize(
  76. "statement",
  77. [
  78. "DELETE FROM orders",
  79. "SELECT 1; DELETE FROM orders",
  80. "COPY orders TO PROGRAM 'id'",
  81. "WITH deleted AS (DELETE FROM orders RETURNING *) SELECT * FROM deleted",
  82. ],
  83. )
  84. def test_sql_query_rejects_non_read_statements(statement):
  85. executor = SqlQueryExecutor(Manager(Rows()))
  86. node = {
  87. "id": "read",
  88. "type": "sql.query",
  89. "data_source_uid": "source-1",
  90. "purpose": "read",
  91. "config": {"statement": statement, "parameters": {}},
  92. }
  93. with pytest.raises(NodeExecutionError, match="read-only"):
  94. executor.execute(node, {})
  95. def test_sql_execute_requires_governed_write_and_idempotency():
  96. manager = Manager(Rows(rowcount=3))
  97. executor = SqlExecuteExecutor(manager)
  98. node = {
  99. "id": "write",
  100. "type": "sql.execute",
  101. "data_source_uid": "source-1",
  102. "purpose": "write",
  103. "config": {
  104. "statement": "UPDATE orders SET ready = :ready WHERE day = :day",
  105. "parameters": {"ready": True, "day": "2026-07-19"},
  106. },
  107. "idempotency": {
  108. "strategy": "deduplication_key",
  109. "key": "orders:2026-07-19",
  110. },
  111. }
  112. result = executor.execute(node, {}, write_authorized=True)
  113. assert result == {"affected_rows": 3, "commit_outcome": "committed"}
  114. assert manager.purposes == [("source-1", "dataflow_write")]
  115. with pytest.raises(NodeExecutionError, match="authorization"):
  116. executor.execute(node, {}, write_authorized=False)
  117. with pytest.raises(NodeExecutionError, match="idempotency"):
  118. executor.execute({**node, "idempotency": {}}, {}, write_authorized=True)
  119. def test_sql_execute_never_converts_unknown_commit_into_a_retryable_failure():
  120. class UnknownManager:
  121. def connect(self, _uid, purpose):
  122. assert purpose == "dataflow_write"
  123. class Context:
  124. def __enter__(self):
  125. return Connection(Rows(rowcount=1))
  126. def __exit__(self, *_args):
  127. error = RuntimeError("connection lost during commit")
  128. error.commit_outcome = "unknown"
  129. raise error
  130. return Context()
  131. node = {
  132. "id": "write",
  133. "type": "sql.execute",
  134. "data_source_uid": "source-1",
  135. "purpose": "write",
  136. "config": {
  137. "statement": "UPDATE orders SET ready = :ready",
  138. "parameters": {"ready": True},
  139. },
  140. "idempotency": {
  141. "strategy": "deduplication_key",
  142. "key": "orders:2026-07-19",
  143. },
  144. }
  145. with pytest.raises(NodeExecutionError) as captured:
  146. SqlExecuteExecutor(UnknownManager()).execute(
  147. node,
  148. {},
  149. write_authorized=True,
  150. )
  151. assert captured.value.commit_outcome == "unknown"
  152. @pytest.mark.parametrize(
  153. "statement",
  154. [
  155. "DROP TABLE orders",
  156. "GRANT ALL ON orders TO public",
  157. "UPDATE orders SET ready = true; DELETE FROM orders",
  158. "SELECT * FROM orders",
  159. ],
  160. )
  161. def test_sql_execute_accepts_dml_only(statement):
  162. node = {
  163. "id": "write",
  164. "type": "sql.execute",
  165. "data_source_uid": "source-1",
  166. "purpose": "write",
  167. "config": {"statement": statement, "parameters": {}},
  168. "idempotency": {
  169. "strategy": "deduplication_key",
  170. "key": "orders:2026-07-19",
  171. },
  172. }
  173. with pytest.raises(NodeExecutionError, match="DML"):
  174. SqlExecuteExecutor(Manager(Rows(rowcount=1))).execute(
  175. node,
  176. {},
  177. write_authorized=True,
  178. )
  179. def test_restricted_python_executes_only_registered_handlers():
  180. executor = RestrictedPythonExecutor(
  181. {"normalize_orders": lambda values: {"count": len(values["orders"])}}
  182. )
  183. node = {
  184. "id": "normalize",
  185. "type": "python",
  186. "config": {
  187. "handler": "normalize_orders",
  188. "arguments": {"orders": [1, 2]},
  189. },
  190. }
  191. assert executor.execute(node, {}) == {"count": 2}
  192. for config in (
  193. {"source": "import os"},
  194. {"handler": "unknown", "arguments": {}},
  195. ):
  196. with pytest.raises(NodeExecutionError):
  197. executor.execute({**node, "config": config}, {})
  198. def test_restricted_python_enforces_timeout_and_no_network():
  199. import socket
  200. import time
  201. def use_network(_arguments):
  202. socket.socket()
  203. def run_forever(_arguments):
  204. time.sleep(1)
  205. node = {
  206. "id": "restricted",
  207. "type": "python",
  208. "config": {"handler": "blocked", "arguments": {}},
  209. }
  210. with pytest.raises(NodeExecutionError, match="network"):
  211. RestrictedPythonExecutor(
  212. {"blocked": use_network},
  213. timeout_seconds=1,
  214. ).execute(node, {})
  215. with pytest.raises(NodeExecutionError, match="time limit"):
  216. RestrictedPythonExecutor(
  217. {"blocked": run_forever},
  218. timeout_seconds=0.05,
  219. ).execute(node, {})
  220. def test_http_node_uses_allowlist_and_never_accepts_inline_authorization():
  221. calls = []
  222. class Session:
  223. def request(self, method, url, **kwargs):
  224. calls.append((method, url, kwargs))
  225. class Response:
  226. status_code = 200
  227. content = b'{"ok":true}'
  228. headers = {"content-type": "application/json"}
  229. def json(self):
  230. return json.loads(self.content)
  231. return Response()
  232. executor = GovernedHttpExecutor(
  233. allowed_hosts={"api.internal.example"},
  234. session=Session(),
  235. )
  236. node = {
  237. "id": "notify",
  238. "type": "http",
  239. "config": {
  240. "method": "POST",
  241. "url": "https://api.internal.example/events",
  242. "json": {"state": "ready"},
  243. },
  244. }
  245. assert executor.execute(node, {})["body"] == {"ok": True}
  246. for config in (
  247. {**node["config"], "url": "http://127.0.0.1/admin"},
  248. {**node["config"], "headers": {"Authorization": "Bearer secret"}},
  249. {**node["config"], "url": "https://user:pass@api.internal.example/events"},
  250. ):
  251. with pytest.raises(NodeExecutionError):
  252. executor.execute({**node, "config": config}, {})
  253. def test_node_registry_fails_closed_for_unregistered_types():
  254. registry = NodeRegistry({"sql.query": object()})
  255. with pytest.raises(NodeExecutionError, match="not registered"):
  256. registry.execute({"type": "notify"}, {})