test_n8n_migration_inventory.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. from __future__ import annotations
  2. import json
  3. import subprocess
  4. import sys
  5. from pathlib import Path
  6. class FakeN8nClient:
  7. def __init__(self):
  8. self.workflow_calls = []
  9. self.execution_calls = []
  10. def list_workflows(self, *, limit, cursor=None):
  11. self.workflow_calls.append(cursor)
  12. if cursor is None:
  13. return {
  14. "data": [
  15. {
  16. "id": "wf-1",
  17. "name": "orders-daily",
  18. "active": True,
  19. "updatedAt": "2026-07-18T01:00:00Z",
  20. "tags": [{"name": "production"}],
  21. "nodes": [
  22. {
  23. "name": "Daily",
  24. "type": "n8n-nodes-base.scheduleTrigger",
  25. "parameters": {"rule": {"interval": [{"field": "days"}]}},
  26. },
  27. {
  28. "name": "Orders DB",
  29. "type": "n8n-nodes-base.postgres",
  30. "parameters": {"operation": "executeQuery"},
  31. "credentials": {
  32. "postgres": {
  33. "id": "credential-id-must-not-leak",
  34. "name": "orders-reader",
  35. "password": "secret-must-not-leak",
  36. }
  37. },
  38. },
  39. ],
  40. "connections": {},
  41. }
  42. ],
  43. "nextCursor": "next-page",
  44. }
  45. return {
  46. "data": [
  47. {
  48. "id": "wf-2",
  49. "name": "community-node-flow",
  50. "active": False,
  51. "nodes": [
  52. {
  53. "name": "Unknown",
  54. "type": "community.exampleNode",
  55. "parameters": {"apiKey": "also-must-not-leak"},
  56. }
  57. ],
  58. "connections": {},
  59. }
  60. ]
  61. }
  62. def list_executions(
  63. self,
  64. *,
  65. workflow_id,
  66. limit,
  67. cursor=None,
  68. include_data=False,
  69. ):
  70. self.execution_calls.append((workflow_id, cursor, include_data))
  71. if workflow_id == "wf-1":
  72. return {
  73. "data": [
  74. {
  75. "id": "execution-1",
  76. "status": "success",
  77. "startedAt": "2026-07-17T01:00:00Z",
  78. "stoppedAt": "2026-07-17T01:02:00Z",
  79. "data": {"password": "execution-secret-must-not-leak"},
  80. }
  81. ]
  82. }
  83. return {"data": []}
  84. ROOT = Path(__file__).resolve().parents[1]
  85. def test_inventory_script_can_run_as_a_direct_cli_entrypoint():
  86. result = subprocess.run(
  87. [
  88. sys.executable,
  89. str(ROOT / "scripts" / "inventory_n8n_workflows.py"),
  90. "--help",
  91. ],
  92. cwd=ROOT,
  93. capture_output=True,
  94. text=True,
  95. )
  96. assert result.returncode == 0, result.stderr
  97. assert "credential-safe n8n migration inventory" in result.stdout
  98. def test_inventory_cli_passes_explicit_timeout_without_flask_context(
  99. monkeypatch, tmp_path
  100. ):
  101. import scripts.inventory_n8n_workflows as inventory
  102. captured = {}
  103. class RecordingClient:
  104. def __init__(self, *, api_url, api_key, timeout):
  105. captured.update(
  106. api_url=api_url,
  107. api_key=api_key,
  108. timeout=timeout,
  109. )
  110. monkeypatch.setattr(inventory, "N8nClient", RecordingClient)
  111. monkeypatch.setattr(
  112. inventory,
  113. "collect_inventory",
  114. lambda client, bindings: inventory.empty_inventory(reason="test"),
  115. )
  116. monkeypatch.setenv("N8N_API_URL", "http://localhost:15678")
  117. monkeypatch.setenv("N8N_API_KEY", "local-test-key")
  118. monkeypatch.setenv("N8N_API_TIMEOUT", "17")
  119. monkeypatch.setattr(
  120. sys,
  121. "argv",
  122. [
  123. "inventory_n8n_workflows.py",
  124. "--output",
  125. str(tmp_path / "inventory.json"),
  126. ],
  127. )
  128. assert inventory.main() == 0
  129. assert captured == {
  130. "api_url": "http://localhost:15678",
  131. "api_key": "local-test-key",
  132. "timeout": 17,
  133. }
  134. def test_collect_inventory_paginates_and_never_exports_secret_material():
  135. from scripts.inventory_n8n_workflows import collect_inventory
  136. result = collect_inventory(
  137. FakeN8nClient(),
  138. bindings={"wf-1": "019b06e4-20e1-7cc2-88b5-bc8e28db3285"},
  139. generated_at="2026-07-18T02:00:00Z",
  140. )
  141. assert result["schema_version"] == "1.0"
  142. assert result["collection_status"] == "complete"
  143. assert result["coverage"] == {
  144. "workflow_count": 2,
  145. "active_count": 1,
  146. "recently_executed_count": 1,
  147. "blocked_count": 1,
  148. "unbound_count": 1,
  149. }
  150. first = result["workflows"][0]
  151. assert first["workflow_id"] == "wf-1"
  152. assert first["dataflow_uid"] == "019b06e4-20e1-7cc2-88b5-bc8e28db3285"
  153. assert first["node_categories"] == ["sql", "trigger"]
  154. assert first["credential_references"] == [
  155. {
  156. "node_name": "Orders DB",
  157. "credential_type": "postgres",
  158. "credential_name": "orders-reader",
  159. }
  160. ]
  161. assert first["recent_execution"]["status_counts"] == {"success": 1}
  162. assert first["migration_assessment"]["production_write"] == "unknown"
  163. assert first["migration_assessment"]["idempotent"] == "unknown"
  164. second = result["workflows"][1]
  165. assert "unsupported_node:community.exampleNode" in second["migration_assessment"][
  166. "blockers"
  167. ]
  168. assert "missing_dataflow_binding" in second["migration_assessment"]["blockers"]
  169. serialized = json.dumps(result, ensure_ascii=False)
  170. for forbidden in (
  171. "credential-id-must-not-leak",
  172. "secret-must-not-leak",
  173. "also-must-not-leak",
  174. "execution-secret-must-not-leak",
  175. ):
  176. assert forbidden not in serialized
  177. def test_empty_collection_is_explicitly_pending_instead_of_claiming_coverage():
  178. from scripts.inventory_n8n_workflows import empty_inventory
  179. result = empty_inventory(
  180. reason="N8N_API_KEY is not configured",
  181. generated_at="2026-07-18T02:00:00Z",
  182. )
  183. assert result["collection_status"] == "pending_live_n8n_access"
  184. assert result["coverage"]["workflow_count"] == 0
  185. assert result["collection_error"] == "N8N_API_KEY is not configured"