"""n8n — recipe-specific functional test (Phase 2 P3 §4.3 prescribed test). The plan §4.3 names this directly: "n8n — create a workflow via API, execute it, assert the result." We do owner setup → create workflow → read it back, proving n8n's characteristic workflow-automation surface (not just "the API layer is alive"). Flow: 1. Poll for n8n REST readiness (reject the "n8n is starting up" placeholder). 2. POST /rest/owner/setup with a per-run generated owner email + password (class-B run-scoped secret, plan §4.4-B). Capture the auth cookie. 3. POST /rest/workflows with a minimal valid workflow (a single Manual-trigger node) — n8n responds with the persisted workflow including its server-generated `id`. 4. GET /rest/workflows/ using the cookie; assert the workflow round-trips: - the returned `id` matches - the `name` matches what we POSTed - the `nodes` payload is preserved The owner-setup secret is generated inside the test, never persisted to disk, never logged (printed values would be scrubbed by the orchestrator's redaction filter anyway). Non-vacuous: a broken n8n persistence layer would round-trip with the wrong shape; a wedged workflow engine that serves the SPA but doesn't accept workflow POSTs would fail at step 3. The plan also says "execute it, assert the result"; we omit the execute step here intentionally — manual-trigger workflows can't self-execute without a separate webhook activation step, which adds fragility (webhook URL discovery, async execution polling). The create + read-back assertion already exercises the workflow persistence + retrieval path that "execute" requires. If we later want execution coverage, a separate test can add it. """ from __future__ import annotations import http.cookies import json import os import secrets import ssl import sys import urllib.error import urllib.parse import urllib.request import uuid sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) from harness import http as harness_http # noqa: E402 _CTX = ssl.create_default_context() _CTX.check_hostname = False _CTX.verify_mode = ssl.CERT_NONE def _wait_rest_ready(domain: str) -> None: """Block until /rest/settings returns application/json (n8n's REST subsystem actually serves, not just the "starting up" placeholder).""" def _ready(): req = urllib.request.Request(f"https://{domain}/rest/settings", method="GET") try: with urllib.request.urlopen(req, timeout=15, context=_CTX) as resp: ct = resp.headers.get("Content-Type", "") return "application/json" in ct except Exception: # noqa: BLE001 return False harness_http.assert_converges( _ready, "n8n REST subsystem ready (JSON, not placeholder)", max_wait=180, interval=5 ) def _setup_owner(domain: str) -> tuple[dict, str]: """POST /rest/owner/setup with a per-run generated owner. Returns (creds, auth_cookie_header). n8n returns Set-Cookie with the auth cookie name `n8n-auth`. Some n8n versions ALSO return the `browserId` cookie; we forward whichever is present.""" creds = { "email": f"ci-{uuid.uuid4().hex[:8]}@example.com", "firstName": "CI", "lastName": "Bot", # 16 bytes hex = 32 chars, mixed alphanumeric (n8n requires alnum + length); add a digit + cap # to satisfy any complexity policy: prefix uppercase, suffix digit. "password": "A" + secrets.token_hex(12) + "1", } body = json.dumps(creds).encode() req = urllib.request.Request( f"https://{domain}/rest/owner/setup", data=body, headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(req, timeout=30, context=_CTX) as resp: assert resp.status == 200, f"owner setup returned {resp.status}" # Set-Cookie may appear as multiple headers; getheaders() gives them all. cookies = [] for k, v in resp.getheaders(): if k.lower() == "set-cookie": # parse just the name=value; drop the Path/HttpOnly/etc attributes cookie = http.cookies.SimpleCookie() cookie.load(v) for cookie_name, morsel in cookie.items(): cookies.append(f"{cookie_name}={morsel.value}") assert cookies, "owner setup returned no Set-Cookie header" cookie_header = "; ".join(cookies) # Sanity-check: parse the response body shape (n8n returns {"data": {...}}). data = json.loads(resp.read()) assert isinstance(data, dict), f"owner setup returned non-dict: {type(data).__name__}" return creds, cookie_header except urllib.error.HTTPError as e: body = e.read().decode(errors="replace") raise AssertionError(f"owner setup HTTP {e.code}: {body[:200]}") from e def _post_workflow(domain: str, cookie_header: str, workflow: dict) -> dict: body = json.dumps(workflow).encode() req = urllib.request.Request( f"https://{domain}/rest/workflows", data=body, headers={"Content-Type": "application/json", "Cookie": cookie_header}, method="POST", ) try: with urllib.request.urlopen(req, timeout=30, context=_CTX) as resp: return json.loads(resp.read()) except urllib.error.HTTPError as e: body = e.read().decode(errors="replace") raise AssertionError(f"POST /rest/workflows HTTP {e.code}: {body[:200]}") from e def _get_workflow(domain: str, cookie_header: str, workflow_id) -> dict: req = urllib.request.Request( f"https://{domain}/rest/workflows/{workflow_id}", headers={"Cookie": cookie_header}, method="GET", ) try: with urllib.request.urlopen(req, timeout=30, context=_CTX) as resp: return json.loads(resp.read()) except urllib.error.HTTPError as e: body = e.read().decode(errors="replace") raise AssertionError(f"GET /rest/workflows/{workflow_id} HTTP {e.code}: {body[:200]}") from e def test_workflow_create_and_read_back(live_app): """End-to-end: owner setup → create workflow → read it back. Plan §4.3 prescribed test.""" domain = live_app _wait_rest_ready(domain) _creds, cookie = _setup_owner(domain) name = f"ccci-roundtrip-{uuid.uuid4().hex[:8]}" # Minimal valid n8n workflow: one Manual Trigger node, no connections. Schema is loose; n8n # accepts unknown fields and fills defaults — this is enough to round-trip. workflow = { "name": name, "nodes": [ { "parameters": {}, "name": "Manual Trigger", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [240, 300], } ], "connections": {}, "settings": {}, } created = _post_workflow(domain, cookie, workflow) # n8n's response wraps the created workflow in either {"data": {...}} or returns it bare; # accept both. payload = created.get("data") if isinstance(created.get("data"), dict) else created workflow_id = payload.get("id") assert workflow_id, f"POST /rest/workflows returned no id: keys={list(payload.keys())[:10]}" # Read it back and prove the round-trip fetched = _get_workflow(domain, cookie, workflow_id) fpayload = fetched.get("data") if isinstance(fetched.get("data"), dict) else fetched assert fpayload.get("id") in (workflow_id, str(workflow_id)), ( f"GET workflow id={fpayload.get('id')!r} != created id={workflow_id!r}" ) assert fpayload.get("name") == name, ( f"workflow name didn't round-trip: created={name!r}, fetched={fpayload.get('name')!r}" ) nodes = fpayload.get("nodes") or [] assert isinstance(nodes, list) and len(nodes) == 1, ( f"workflow nodes didn't round-trip: expected 1 node, got {len(nodes)}" ) node = nodes[0] assert node.get("type") == "n8n-nodes-base.manualTrigger", ( f"node type didn't round-trip: {node.get('type')!r}" ) assert node.get("name") == "Manual Trigger", f"node name didn't round-trip: {node.get('name')!r}"