Files
cc-ci/tests/n8n/functional/test_rest_settings.py
autonomic-bot 2f3d5aa78f feat(2): Q1.2 — n8n Phase-2 parity + functional + robust install (full e2e green)
- tests/n8n/PARITY.md: parity table (health_check ported) + 2 recipe-specific
  functional tests with rationale + data-integrity section pointing to
  Phase-1d/1e lifecycle overlays.
- tests/n8n/functional/test_health_check.py: parity port of
  recipe-info/n8n/tests/health_check.py — SOURCE comment.
- tests/n8n/functional/test_rest_settings.py: NEW recipe-specific — polls
  /rest/settings until response is application/json (not the 'n8n is starting
  up' SPA placeholder); asserts known n8n public-settings keys
  (userManagement/defaultLocale/authCookie) in the 'data' envelope. Proves the
  editor SPA's primary API contract is intact.
- tests/n8n/functional/test_login_state.py: NEW recipe-specific — polls
  /rest/login until response is JSON; proves the user-management/auth subsystem
  initialized on top of the public-settings layer.
- tests/n8n/test_install.py: install overlay's Playwright now polls page.goto
  until status==200 (n8n's / route can return 404 briefly while the SPA route
  registers on top of /healthz=200). Bounded poll, no bare sleep, raise on
  persistent failure — same robustness pattern as Phase-1e exec_in_app.

Cold-verifiable on cc-ci (log /root/ccci-q1-n8n-r3.log):
  RECIPE=n8n cc-ci-run runner/run_recipe_ci.py
  all 5 stages PASS, deploy-count=1, head_ref=63dd3e0f==chaos-version=63dd3e0f,
  version 3.1.0+2.9.4 -> 3.2.0+2.20.6 (HC1 non-vacuous), 5 lifecycle assertions
  + 3 custom-stage assertions all PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 06:48:00 +01:00

89 lines
4.1 KiB
Python

"""n8n — recipe-specific functional test (Phase 2 P3, ≥2 beyond parity).
n8n's editor SPA bootstraps by calling `/rest/settings` to obtain its public settings (webhook URL
base, version, defaults). A working n8n returns a JSON body from this endpoint; a still-starting
n8n returns the placeholder HTML page "n8n is starting up. Please wait" with the SAME 200 status.
So this test polls until the response is real JSON (not the SPA fallback) and asserts known n8n
public-settings keys are present. Non-vacuous: a broken n8n that boots but cannot serve its public
settings (the API the editor SPA literally requires) would fail here, distinguishing it from a
"the HTTP layer is up but n8n itself is dead" failure that /healthz can't catch.
Runs in the custom tier against the shared post-install deployment (live_app).
"""
from __future__ import annotations
import json
import os
import ssl
import sys
import urllib.request
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http # noqa: E402
def _raw_get_json(url: str, timeout: int = 15):
"""GET the URL and parse the body as JSON only if Content-Type is application/json — n8n's
"starting up" placeholder returns text/html with status 200, so a content-type check
distinguishes a real JSON response from the SPA fallback without relying on path-specific shape.
Returns (status, content_type, json_or_None)."""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
ct = resp.headers.get("Content-Type", "")
raw = resp.read()
except Exception as e: # noqa: BLE001
return 0, "", None, str(e)
if "application/json" not in ct:
return resp.status, ct, None, raw.decode(errors="replace")[:80]
try:
return resp.status, ct, json.loads(raw), None
except (json.JSONDecodeError, ValueError) as e:
return resp.status, ct, None, str(e)
def test_rest_settings_returns_json_with_known_keys(live_app):
"""Poll /rest/settings until it returns real JSON (not the "starting up" SPA placeholder), then
assert known n8n public-settings keys are present in the response — proves the editor's primary
API contract is intact."""
url = f"https://{live_app}/rest/settings"
last_state: dict = {}
def _ready():
status, ct, body, err = _raw_get_json(url)
last_state.update({"status": status, "ct": ct, "err": err})
return body if (status == 200 and body is not None) else None
body = harness_http.assert_converges(
_ready,
f"GET {url} returns JSON (n8n REST ready)",
max_wait=180,
interval=5,
)
# n8n /rest/settings shape: {"data": {<settings dict>}, ...}. The exact key set evolves over
# versions; assert STRUCTURE (the "data" envelope) + a few stable bootstrap keys that have been
# part of /rest/settings since n8n introduced user-management — verified on this live deploy
# (e.g. version 3.2.0+2.20.6).
assert isinstance(body, dict), f"/rest/settings returned non-dict JSON: {type(body).__name__}"
data = body.get("data") if "data" in body else body
assert isinstance(data, dict), (
f"/rest/settings response missing 'data' envelope: keys={list(body.keys())[:10]}"
)
# Bootstrap keys the editor SPA relies on across versions:
# - `userManagement`: the auth-mode dict (whether owner-setup is needed, smtp/email mode).
# - `defaultLocale`: i18n bootstrap; present on every n8n install.
# - `authCookie`: the auth-cookie configuration; consumed by the SPA on every page load.
expected_any = ("userManagement", "defaultLocale", "authCookie")
present = [k for k in expected_any if k in data]
assert present, (
f"/rest/settings 'data' missing all of {expected_any}; "
f"got keys: {sorted(data.keys())[:20]} — n8n version may have rotated these names; "
"update the test."
)