mirror of
https://github.com/Comfy-Org/ComfyUI-Manager.git
synced 2026-05-09 16:42:32 +08:00
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)
309 lines
12 KiB
Python
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]}"
|
|
)
|