| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212 |
- from __future__ import annotations
- import json
- import subprocess
- import sys
- from pathlib import Path
- class FakeN8nClient:
- def __init__(self):
- self.workflow_calls = []
- self.execution_calls = []
- def list_workflows(self, *, limit, cursor=None):
- self.workflow_calls.append(cursor)
- if cursor is None:
- return {
- "data": [
- {
- "id": "wf-1",
- "name": "orders-daily",
- "active": True,
- "updatedAt": "2026-07-18T01:00:00Z",
- "tags": [{"name": "production"}],
- "nodes": [
- {
- "name": "Daily",
- "type": "n8n-nodes-base.scheduleTrigger",
- "parameters": {"rule": {"interval": [{"field": "days"}]}},
- },
- {
- "name": "Orders DB",
- "type": "n8n-nodes-base.postgres",
- "parameters": {"operation": "executeQuery"},
- "credentials": {
- "postgres": {
- "id": "credential-id-must-not-leak",
- "name": "orders-reader",
- "password": "secret-must-not-leak",
- }
- },
- },
- ],
- "connections": {},
- }
- ],
- "nextCursor": "next-page",
- }
- return {
- "data": [
- {
- "id": "wf-2",
- "name": "community-node-flow",
- "active": False,
- "nodes": [
- {
- "name": "Unknown",
- "type": "community.exampleNode",
- "parameters": {"apiKey": "also-must-not-leak"},
- }
- ],
- "connections": {},
- }
- ]
- }
- def list_executions(
- self,
- *,
- workflow_id,
- limit,
- cursor=None,
- include_data=False,
- ):
- self.execution_calls.append((workflow_id, cursor, include_data))
- if workflow_id == "wf-1":
- return {
- "data": [
- {
- "id": "execution-1",
- "status": "success",
- "startedAt": "2026-07-17T01:00:00Z",
- "stoppedAt": "2026-07-17T01:02:00Z",
- "data": {"password": "execution-secret-must-not-leak"},
- }
- ]
- }
- return {"data": []}
- ROOT = Path(__file__).resolve().parents[1]
- def test_inventory_script_can_run_as_a_direct_cli_entrypoint():
- result = subprocess.run(
- [
- sys.executable,
- str(ROOT / "scripts" / "inventory_n8n_workflows.py"),
- "--help",
- ],
- cwd=ROOT,
- capture_output=True,
- text=True,
- )
- assert result.returncode == 0, result.stderr
- assert "credential-safe n8n migration inventory" in result.stdout
- def test_inventory_cli_passes_explicit_timeout_without_flask_context(
- monkeypatch, tmp_path
- ):
- import scripts.inventory_n8n_workflows as inventory
- captured = {}
- class RecordingClient:
- def __init__(self, *, api_url, api_key, timeout):
- captured.update(
- api_url=api_url,
- api_key=api_key,
- timeout=timeout,
- )
- monkeypatch.setattr(inventory, "N8nClient", RecordingClient)
- monkeypatch.setattr(
- inventory,
- "collect_inventory",
- lambda client, bindings: inventory.empty_inventory(reason="test"),
- )
- monkeypatch.setenv("N8N_API_URL", "http://localhost:15678")
- monkeypatch.setenv("N8N_API_KEY", "local-test-key")
- monkeypatch.setenv("N8N_API_TIMEOUT", "17")
- monkeypatch.setattr(
- sys,
- "argv",
- [
- "inventory_n8n_workflows.py",
- "--output",
- str(tmp_path / "inventory.json"),
- ],
- )
- assert inventory.main() == 0
- assert captured == {
- "api_url": "http://localhost:15678",
- "api_key": "local-test-key",
- "timeout": 17,
- }
- def test_collect_inventory_paginates_and_never_exports_secret_material():
- from scripts.inventory_n8n_workflows import collect_inventory
- result = collect_inventory(
- FakeN8nClient(),
- bindings={"wf-1": "019b06e4-20e1-7cc2-88b5-bc8e28db3285"},
- generated_at="2026-07-18T02:00:00Z",
- )
- assert result["schema_version"] == "1.0"
- assert result["collection_status"] == "complete"
- assert result["coverage"] == {
- "workflow_count": 2,
- "active_count": 1,
- "recently_executed_count": 1,
- "blocked_count": 1,
- "unbound_count": 1,
- }
- first = result["workflows"][0]
- assert first["workflow_id"] == "wf-1"
- assert first["dataflow_uid"] == "019b06e4-20e1-7cc2-88b5-bc8e28db3285"
- assert first["node_categories"] == ["sql", "trigger"]
- assert first["credential_references"] == [
- {
- "node_name": "Orders DB",
- "credential_type": "postgres",
- "credential_name": "orders-reader",
- }
- ]
- assert first["recent_execution"]["status_counts"] == {"success": 1}
- assert first["migration_assessment"]["production_write"] == "unknown"
- assert first["migration_assessment"]["idempotent"] == "unknown"
- second = result["workflows"][1]
- assert "unsupported_node:community.exampleNode" in second["migration_assessment"][
- "blockers"
- ]
- assert "missing_dataflow_binding" in second["migration_assessment"]["blockers"]
- serialized = json.dumps(result, ensure_ascii=False)
- for forbidden in (
- "credential-id-must-not-leak",
- "secret-must-not-leak",
- "also-must-not-leak",
- "execution-secret-must-not-leak",
- ):
- assert forbidden not in serialized
- def test_empty_collection_is_explicitly_pending_instead_of_claiming_coverage():
- from scripts.inventory_n8n_workflows import empty_inventory
- result = empty_inventory(
- reason="N8N_API_KEY is not configured",
- generated_at="2026-07-18T02:00:00Z",
- )
- assert result["collection_status"] == "pending_live_n8n_access"
- assert result["coverage"]["workflow_count"] == 0
- assert result["collection_error"] == "N8N_API_KEY is not configured"
|