ComfyUI-Manager/tests/e2e/test_e2e_legacy_endpoints.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

309 lines
12 KiB
Python

"""E2E positive-path tests for legacy-only GET endpoints.
SCOPE — closes the 6 pytest-N gaps from reports/api-coverage-matrix.md
(WI-TT). Each target endpoint is registered ONLY in
`comfyui_manager/legacy/manager_server.py` and thus reachable only when
ComfyUI runs with `--enable-manager-legacy-ui`.
Endpoints covered:
- GET /customnode/alternatives (legacy L1072)
- GET /v2/customnode/disabled_versions/{node_name} (legacy L1273)
- GET /v2/customnode/getlist (legacy L1018)
- GET /v2/customnode/versions/{node_name} (legacy L1262)
- GET /v2/externalmodel/getlist (legacy L1143)
- GET /v2/manager/notice (legacy L1747)
Why a separate file:
comfyui_manager/__init__.py loads `glob.manager_server` XOR
`legacy.manager_server` (mutex via args.enable_manager_legacy_ui), so
these routes do not exist under the glob-mode fixture used by most
other E2E suites. Mirrors the fixture pattern in
`test_e2e_csrf_legacy.py` — separate module-scoped `comfyui_legacy`
fixture that launches via `start_comfyui_legacy.sh`.
Handler-shape notes (for reviewers):
- disabled_versions returns HTTP 400 when the given node has NO
disabled versions (handler L1283-1286). This is not a param-validation
error — it is the handler's convention for "empty result". The seed
E2E pack (`ComfyUI_SigmoidOffsetScheduler`) installs cleanly with no
disabled versions, so the positive path here asserts the endpoint is
reachable and the param parses — status ∈ {200, 400} with per-branch
body schema checks. Documented upstream as a handler design quirk.
- notice returns `text/html` (not JSON); handler L1787 returns
`web.Response(text=markdown_content, status=200)`.
Requires a pre-built E2E environment (from setup_e2e_env.sh).
"""
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}"
SEED_PACK = "ComfyUI_SigmoidOffsetScheduler"
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",
)
def _start_comfyui_legacy() -> int:
env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
r = subprocess.run(
["bash", os.path.join(SCRIPTS_DIR, "start_comfyui_legacy.sh")],
capture_output=True,
text=True,
timeout=180,
env=env,
)
if r.returncode != 0:
raise RuntimeError(f"Failed to start ComfyUI (legacy):\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:\n{r.stdout}")
def _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,
)
@pytest.fixture(scope="module")
def comfyui_legacy():
pid = _start_comfyui_legacy()
yield pid
_stop_comfyui()
class TestLegacyCustomNodeAlternatives:
"""GET /customnode/alternatives?mode=local (wi-031)."""
def test_returns_dict_of_alternatives(self, comfyui_legacy):
resp = requests.get(
f"{BASE_URL}/customnode/alternatives",
params={"mode": "local"},
timeout=30,
)
assert resp.status_code == 200, (
f"Expected 200, got {resp.status_code}: {resp.text[:200]}"
)
data = resp.json()
assert isinstance(data, dict), (
f"alternatives response should be a dict keyed by unified id, "
f"got {type(data).__name__}"
)
class TestLegacyCustomNodeDisabledVersions:
"""GET /v2/customnode/disabled_versions/{node_name} (wi-032).
The handler returns 200 + list[{version}] when disabled versions exist,
and 400 otherwise (empty-result convention — not a validation error).
Seed pack has no disabled versions, so positive path here is:
endpoint reachable + param parsed correctly + response-shape on each
branch.
"""
def test_endpoint_reachable_and_parses_param(self, comfyui_legacy):
resp = requests.get(
f"{BASE_URL}/v2/customnode/disabled_versions/{SEED_PACK}",
timeout=10,
)
assert resp.status_code in (200, 400), (
f"disabled_versions should return 200 (has disabled) or 400 "
f"(none), got {resp.status_code}: {resp.text[:200]}"
)
if resp.status_code == 200:
data = resp.json()
assert isinstance(data, list), (
f"disabled_versions 200 body should be a list, "
f"got {type(data).__name__}"
)
for entry in data:
assert "version" in entry, (
f"each entry should have 'version' key, got {entry!r}"
)
class TestLegacyCustomNodeGetList:
"""GET /v2/customnode/getlist?mode=local (wi-033)."""
def test_returns_channel_and_node_packs(self, comfyui_legacy):
resp = requests.get(
f"{BASE_URL}/v2/customnode/getlist",
params={"mode": "local", "skip_update": "true"},
timeout=60,
)
assert resp.status_code == 200, (
f"Expected 200, got {resp.status_code}: {resp.text[:200]}"
)
data = resp.json()
assert isinstance(data, dict), (
f"getlist response should be a dict, got {type(data).__name__}"
)
assert "channel" in data, (
f"getlist response missing 'channel' field: keys={list(data)}"
)
assert "node_packs" in data, (
f"getlist response missing 'node_packs' field: keys={list(data)}"
)
assert isinstance(data["node_packs"], dict), (
f"node_packs should be a dict, got {type(data['node_packs']).__name__}"
)
class TestLegacyCustomNodeVersions:
"""GET /v2/customnode/versions/{node_name} (wi-034).
The seed pack is a CNR pack and should have at least one version.
"""
def test_returns_versions_list_for_seed_pack(self, comfyui_legacy):
resp = requests.get(
f"{BASE_URL}/v2/customnode/versions/{SEED_PACK}",
timeout=15,
)
assert resp.status_code == 200, (
f"Expected 200 for known CNR pack {SEED_PACK!r}, "
f"got {resp.status_code}: {resp.text[:200]}"
)
data = resp.json()
assert isinstance(data, list), (
f"versions response should be a list, got {type(data).__name__}"
)
assert len(data) > 0, (
f"CNR pack {SEED_PACK!r} should report at least one version, "
f"got empty list"
)
class TestLegacyExternalModelGetList:
"""GET /v2/externalmodel/getlist?mode=local (wi-035)."""
def test_returns_models_payload(self, comfyui_legacy):
resp = requests.get(
f"{BASE_URL}/v2/externalmodel/getlist",
params={"mode": "local"},
timeout=30,
)
assert resp.status_code == 200, (
f"Expected 200, got {resp.status_code}: {resp.text[:200]}"
)
data = resp.json()
assert isinstance(data, dict), (
f"externalmodel/getlist should return a dict, "
f"got {type(data).__name__}"
)
assert "models" in data, (
f"externalmodel/getlist missing 'models' field: keys={list(data)}"
)
assert isinstance(data["models"], list), (
f"'models' should be a list, got {type(data['models']).__name__}"
)
class TestLegacyManagerNotice:
"""GET /v2/manager/notice (wi-036).
Returns text/html (not JSON) — handler concatenates markdown fragments
or a fallback 'Unable to retrieve Notice' string. Both branches return
HTTP 200, so the positive-path assertion is status + non-empty body.
"""
def test_returns_text_body(self, comfyui_legacy):
resp = requests.get(f"{BASE_URL}/v2/manager/notice", timeout=30)
assert resp.status_code == 200, (
f"Expected 200, got {resp.status_code}: {resp.text[:200]}"
)
assert resp.text, "notice body should be non-empty (markdown or fallback)"
class TestLegacyQueueBatch:
"""POST /v2/manager/queue/batch (wi-039).
Handler shape (legacy/manager_server.py:740-801):
- Request: JSON dict whose top-level keys select actions —
update_all | reinstall | install | uninstall | update |
update_comfyui | disable | install_model | fix.
Unrecognized keys are silently skipped (no-match falls through
the if/elif chain).
- Response: always `{"failed": [list of failed ids]}`, status 200.
- Side effect: `finalize_temp_queue_batch(json_data, failed)` writes
a batch snapshot to the history store IFF the action helpers
populated `temp_queue_batch`. With an empty or unrecognized-key
payload, `temp_queue_batch` stays empty and no history is written
(guard: `if len(temp_queue_batch):` at L444).
- `_queue_start()` is called unconditionally to nudge the worker.
Safe-payload choice: empty JSON body `{}`. Rationale —
(a) exercises the full handler path (request parse → action
for-loop no-op → finalize-with-empty → queue_start → 200 json),
(b) leaves zero side effects on installed packs / disk state,
(c) still round-trips through the aiohttp handler lock and
`temp_queue_batch` snapshot guard so a future regression
(e.g., unconditional history write, lock deadlock) would be
caught.
The dispatch's side-effect verification is covered indirectly: the
test asserts queue/status is still 200 after POST, proving the lock
released and the worker nudge completed cleanly. History-growth
verification would require an expensive mutating batch, which the
dispatch explicitly discourages.
"""
def test_accepts_empty_payload_returns_failed_list(self, comfyui_legacy):
resp = requests.post(
f"{BASE_URL}/v2/manager/queue/batch",
json={},
timeout=15,
)
assert resp.status_code == 200, (
f"Expected 200 for empty-batch payload, got "
f"{resp.status_code}: {resp.text[:200]}"
)
data = resp.json()
assert isinstance(data, dict), (
f"queue/batch response should be a dict, "
f"got {type(data).__name__}"
)
assert "failed" in data, (
f"queue/batch response missing 'failed' key: {data!r}"
)
assert isinstance(data["failed"], list), (
f"'failed' should be a list, got {type(data['failed']).__name__}"
)
assert data["failed"] == [], (
f"no actions performed → 'failed' should be empty, "
f"got {data['failed']!r}"
)
# Side-effect liveness check: queue/status still 200 after POST,
# proving the worker lock was released cleanly.
status_resp = requests.get(
f"{BASE_URL}/v2/manager/queue/status", timeout=10
)
assert status_resp.status_code == 200, (
f"queue/status should remain callable after queue/batch POST, "
f"got {status_resp.status_code}: {status_resp.text[:200]}"
)