ComfyUI-Manager/tests/e2e/test_e2e_version_mgmt.py
Dr.Lt.Data 4410ebc6a6
Some checks are pending
Publish to PyPI / build-and-publish (push) Waiting to run
Python Linting / Run Ruff (push) Waiting to run
fix(security): harden CSRF with Content-Type gate and expand E2E coverage (#2818)
Defense-in-depth over GET→POST alone: reject the three CORS-safelisted
simple-form Content-Types (x-www-form-urlencoded, multipart/form-data,
text/plain) on 16 no-body POST handlers (glob + legacy) to block
<form method=POST> CSRF that bypasses method-only gating. Move
comfyui_switch_version to a JSON body so the preflight requirement applies.
Split db_mode/policy/update/channel_url_list into GET(read) + POST(write).
Tighten do_fix (high → high+) and gate three previously-ungated config
setters at middle. Resynchronize openapi.yaml (27 paths, 30 operations,
ComfyUISwitchVersionParams as a shared $ref component). Add E2E harness
variants, Playwright config, CSRF/secgate suites, 39-endpoint coverage,
and a CHANGELOG.

Breaking: legacy per-op POST routes (install/uninstall/fix/disable/update/
reinstall/abort_current) are removed; callers already use queue/batch.
Legacy /manager/notice (v1) is removed; /v2/manager/notice is retained.

Reported-by: XlabAI Team of Tencent Xuanwu Lab
CVSS: 8.1 (AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H)
2026-04-22 05:04:30 +09:00

243 lines
9.7 KiB
Python

"""E2E tests for ComfyUI Manager version management endpoints.
Exercises the version management endpoints on a running ComfyUI instance:
- GET /v2/comfyui_manager/comfyui_versions — list versions + current
- POST /v2/comfyui_manager/comfyui_switch_version — switch version (negative tests only)
Scenario:
List versions → verify response has 'versions' array and 'current'
string. For switch_version: test missing params returns 400 (actual
version switching is destructive and NOT tested here).
Requires a pre-built E2E environment (from setup_e2e_env.sh).
Set E2E_ROOT env var to point at it, or the tests will be skipped.
Usage:
E2E_ROOT=/tmp/e2e_full_test pytest tests/e2e/test_e2e_version_mgmt.py -v
"""
from __future__ import annotations
import os
import subprocess
import pytest
import requests
E2E_ROOT = os.environ.get("E2E_ROOT", "")
COMFYUI_PATH = os.path.join(E2E_ROOT, "comfyui") if E2E_ROOT else ""
SCRIPTS_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "scripts"
)
PORT = 8199
BASE_URL = f"http://127.0.0.1:{PORT}"
pytestmark = pytest.mark.skipif(
not E2E_ROOT
or not os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete")),
reason="E2E_ROOT not set or E2E environment not ready (run setup_e2e_env.sh first)",
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _start_comfyui() -> int:
"""Start ComfyUI and return its PID."""
env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
r = subprocess.run(
["bash", os.path.join(SCRIPTS_DIR, "start_comfyui.sh")],
capture_output=True,
text=True,
timeout=180,
env=env,
)
if r.returncode != 0:
raise RuntimeError(f"Failed to start ComfyUI:\n{r.stderr}")
for part in r.stdout.strip().split():
if part.startswith("COMFYUI_PID="):
return int(part.split("=")[1])
raise RuntimeError(f"Could not parse PID from start_comfyui output:\n{r.stdout}")
def _stop_comfyui():
"""Stop ComfyUI."""
env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
subprocess.run(
["bash", os.path.join(SCRIPTS_DIR, "stop_comfyui.sh")],
capture_output=True,
text=True,
timeout=30,
env=env,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def comfyui():
"""Start ComfyUI once for the module, stop after all tests."""
pid = _start_comfyui()
yield pid
_stop_comfyui()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestComfyUIVersions:
"""Verify /v2/comfyui_manager/comfyui_versions response structure."""
def test_versions_response_contract(self, comfyui):
"""GET /v2/comfyui_manager/comfyui_versions — full response contract.
Merged by WI-NN (bloat Priority 3, Cluster 7): absorbs the four previous
single-GET tests (test_versions_endpoint + test_versions_list_not_empty +
test_versions_items_are_strings + test_current_is_in_versions) into one
contract block. All 4 original tests hit the same endpoint; merging
removes 3 redundant round-trips and keeps every unique assertion.
"""
resp = requests.get(
f"{BASE_URL}/v2/comfyui_manager/comfyui_versions", timeout=10
)
# (a) status + top-level schema (was test_versions_endpoint)
assert resp.status_code == 200, (
f"comfyui_versions failed with status {resp.status_code}"
)
data = resp.json()
assert "versions" in data, "Response missing 'versions' field"
assert "current" in data, "Response missing 'current' field"
assert isinstance(data["versions"], list), (
f"'versions' should be a list, got {type(data['versions'])}"
)
assert isinstance(data["current"], str), (
f"'current' should be a string, got {type(data['current'])}"
)
# (b) versions list is non-empty (was test_versions_list_not_empty)
assert len(data["versions"]) > 0, (
"Expected at least one version in the list"
)
# (c) every entry is a string (was test_versions_items_are_strings)
for v in data["versions"]:
assert isinstance(v, str), (
f"Version entry should be a string, got {type(v)}: {v}"
)
# (d) current appears in versions list (was test_current_is_in_versions).
# Keep the "empty current" guard — handler emits "" if git state can't
# resolve a tag, which is non-ideal but not a contract violation.
if data["current"] and data["versions"]:
assert data["current"] in data["versions"], (
f"Current version '{data['current']}' not found in versions list"
)
class TestSwitchVersionNegative:
"""Negative tests for /v2/comfyui_manager/comfyui_switch_version.
Actual version switching is destructive and NOT exercised.
Only error paths (missing params, validation failures) are tested.
"""
@pytest.mark.parametrize(
"req_params",
[
pytest.param(None, id="no-params"),
pytest.param({"ver": "v1.0.0"}, id="partial-params-ver-only"),
],
)
def test_switch_version_missing_required_params_rejected(self, comfyui, req_params):
"""POST without full (ver, client_id, ui_id) must be rejected.
WI-OO Item 5 (bloat dbg:ci-018 B9+B1): merges the previously-separate
`missing_all_params` and `missing_client_id` tests. At the default
security_level=normal the high+ gate returns 403 BEFORE any param
validation runs, so both fully-empty and partial-param requests
exercise the same rejection path. Parametrized across both input
equivalence classes — keeps both inputs exercised as distinct
pytest invocations for diagnostics, without duplicating the body.
WI #258: Migrated from query-string (params=) to JSON body (json=).
When req_params is None we send no body at all (bare POST).
"""
url = f"{BASE_URL}/v2/comfyui_manager/comfyui_switch_version"
if req_params is None:
resp = requests.post(url, timeout=10)
else:
resp = requests.post(url, json=req_params, timeout=10)
assert resp.status_code in (400, 403), (
f"Expected 400 or 403 for missing/partial params "
f"(req_params={req_params!r}), got {resp.status_code}"
)
def test_switch_version_validation_error_body(self, comfyui):
"""Validation error (400) returns structured Pydantic error body.
WI-L strengthening: previously accepted 'error field present OR plain
text'. The contract is stricter — the ValidationError path emits
exactly:
{"error": "Validation error", "details": [<pydantic error entries>]}
We now assert the full schema: the `error` sentinel string, the
`details` list, and that each detail entry carries the Pydantic
triplet (loc / msg / type). This defeats a regression where the server
falls through to the generic `except Exception` branch (which returns
status=400 with an EMPTY body — would currently still pass old check).
WI #258: Send a well-formed JSON body with required fields missing
to reach the Pydantic validator (not the json.JSONDecodeError branch,
which produces a plain-text 400). An empty JSON object {} fails the
required-field check for `ver`/`client_id`/`ui_id` uniformly.
"""
resp = requests.post(
f"{BASE_URL}/v2/comfyui_manager/comfyui_switch_version",
json={},
timeout=10,
)
if resp.status_code == 403:
pytest.skip(
"Server security level blocks switch_version with 403 before "
"validation runs; validation-error-body contract not reachable"
)
assert resp.status_code == 400, (
f"Expected 400 validation error, got {resp.status_code}: {resp.text[:200]}"
)
# Pydantic validation returns JSON with 'error' + 'details' list.
try:
data = resp.json()
except requests.exceptions.JSONDecodeError:
pytest.fail(
f"400 response should be JSON but got plain text: {resp.text[:200]}"
)
assert "error" in data, (
f"400 response must include 'error' field, got: {data!r}"
)
assert data["error"] == "Validation error", (
f"'error' field must be the exact 'Validation error' sentinel, got {data['error']!r}"
)
assert "details" in data, (
f"400 response must include 'details' list, got: {data!r}"
)
details = data["details"]
assert isinstance(details, list), (
f"'details' must be a list, got {type(details).__name__}"
)
assert len(details) >= 1, (
"'details' must contain at least one Pydantic error entry, got empty list"
)
# Each entry is a Pydantic error dict with canonical keys.
for i, entry in enumerate(details):
assert isinstance(entry, dict), (
f"details[{i}] must be a dict, got {type(entry).__name__}"
)
for required_key in ("loc", "msg", "type"):
assert required_key in entry, (
f"details[{i}] missing Pydantic key {required_key!r}: {entry!r}"
)