ComfyUI/tests-unit/app_test/frontend_manager_test.py
Glary-Bot bb2c1db8c7 harden: validate metadata shape and refuse out-of-dir cleanup paths
Addresses review feedback on the auto-managed metadata helpers:

- json.load() on the metadata file can return non-dict values (e.g. a
  bare list or a string); guard the root type before calling .get().
- A tampered or hand-edited .auto_managed.json could contain entries
  like '../escape'. The previous code happily fed those into rmtree.
  Filter such entries out at both read time and write time so they
  never reach disk or cleanup, and add a belt-and-suspenders path
  containment check in _prune_auto_managed_versions that requires the
  resolved target to live strictly under the resolved provider dir.
2026-06-10 19:16:36 +00:00

514 lines
17 KiB
Python

import argparse
from pathlib import Path
import pytest
from requests.exceptions import HTTPError
from unittest.mock import patch, mock_open
from app.frontend_management import (
FrontendManager,
FrontEndProvider,
Release,
)
from comfy.cli_args import DEFAULT_VERSION_STRING
@pytest.fixture
def mock_releases():
return [
Release(
id=1,
tag_name="1.0.0",
name="Release 1.0.0",
prerelease=False,
created_at="2022-01-01T00:00:00Z",
published_at="2022-01-01T00:00:00Z",
body="Release notes for 1.0.0",
assets=[{"name": "dist.zip", "url": "https://example.com/dist.zip"}],
),
Release(
id=2,
tag_name="2.0.0",
name="Release 2.0.0",
prerelease=False,
created_at="2022-02-01T00:00:00Z",
published_at="2022-02-01T00:00:00Z",
body="Release notes for 2.0.0",
assets=[{"name": "dist.zip", "url": "https://example.com/dist.zip"}],
),
]
@pytest.fixture
def mock_provider(mock_releases):
provider = FrontEndProvider(
owner="test-owner",
repo="test-repo",
)
provider.all_releases = mock_releases
provider.latest_release = mock_releases[1]
FrontendManager.PROVIDERS = [provider]
return provider
@pytest.fixture(autouse=True)
def clear_cache():
import utils.install_util
import app.frontend_management
utils.install_util.PACKAGE_VERSIONS = {}
app.frontend_management.COMFY_PACKAGE_VERSIONS = []
def test_get_release(mock_provider, mock_releases):
version = "1.0.0"
release = mock_provider.get_release(version)
assert release == mock_releases[0]
def test_get_release_latest(mock_provider, mock_releases):
version = "latest"
release = mock_provider.get_release(version)
assert release == mock_releases[1]
def test_get_release_invalid_version(mock_provider):
version = "invalid"
with pytest.raises(ValueError):
mock_provider.get_release(version)
def test_init_frontend_default():
version_string = DEFAULT_VERSION_STRING
frontend_path = FrontendManager.init_frontend(version_string)
assert frontend_path == FrontendManager.default_frontend_path()
def test_init_frontend_invalid_version():
version_string = "test-owner/test-repo@1.100.99"
with pytest.raises(HTTPError):
FrontendManager.init_frontend_unsafe(version_string)
def test_init_frontend_invalid_provider():
version_string = "invalid/invalid@latest"
with pytest.raises(HTTPError):
FrontendManager.init_frontend_unsafe(version_string)
@pytest.fixture
def mock_os_functions():
with (
patch("app.frontend_management.os.makedirs") as mock_makedirs,
patch("app.frontend_management.os.listdir") as mock_listdir,
patch("app.frontend_management.os.rmdir") as mock_rmdir,
):
mock_listdir.return_value = [] # Simulate empty directory
yield mock_makedirs, mock_listdir, mock_rmdir
@pytest.fixture
def mock_download():
with patch("app.frontend_management.download_release_asset_zip") as mock:
mock.side_effect = Exception("Download failed") # Simulate download failure
yield mock
def test_finally_block(mock_os_functions, mock_download, mock_provider):
# Arrange
mock_makedirs, mock_listdir, mock_rmdir = mock_os_functions
version_string = "test-owner/test-repo@1.0.0"
# Act & Assert
with pytest.raises(Exception):
FrontendManager.init_frontend_unsafe(version_string, mock_provider)
# Assert
mock_makedirs.assert_called_once()
mock_download.assert_called_once()
mock_listdir.assert_called_once()
mock_rmdir.assert_called_once()
def test_parse_version_string():
version_string = "owner/repo@1.0.0"
repo_owner, repo_name, version = FrontendManager.parse_version_string(
version_string
)
assert repo_owner == "owner"
assert repo_name == "repo"
assert version == "1.0.0"
def test_parse_version_string_invalid():
version_string = "invalid"
with pytest.raises(argparse.ArgumentTypeError):
FrontendManager.parse_version_string(version_string)
def test_init_frontend_default_with_mocks():
# Arrange
version_string = DEFAULT_VERSION_STRING
# Act
with (
patch("app.frontend_management.check_comfy_packages_versions") as mock_check,
patch.object(
FrontendManager, "default_frontend_path", return_value="/mocked/path"
),
):
frontend_path = FrontendManager.init_frontend(version_string)
# Assert
assert frontend_path == "/mocked/path"
mock_check.assert_called_once()
def test_init_frontend_fallback_on_error():
# Arrange
version_string = "test-owner/test-repo@1.0.0"
# Act
with (
patch.object(
FrontendManager, "init_frontend_unsafe", side_effect=Exception("Test error")
),
patch("app.frontend_management.check_comfy_packages_versions") as mock_check,
patch.object(
FrontendManager, "default_frontend_path", return_value="/default/path"
),
):
frontend_path = FrontendManager.init_frontend(version_string)
# Assert
assert frontend_path == "/default/path"
mock_check.assert_called_once()
def test_get_frontend_version():
# Arrange
expected_version = "1.25.0"
mock_requirements_content = """torch
torchsde
comfyui-frontend-package==1.25.0
other-package==1.0.0
numpy"""
# Act
with patch("builtins.open", mock_open(read_data=mock_requirements_content)):
version = FrontendManager.get_required_frontend_version()
# Assert
assert version == expected_version
def test_get_frontend_version_invalid_semver():
# Arrange
mock_requirements_content = """torch
torchsde
comfyui-frontend-package==1.29.3.75
other-package==1.0.0
numpy"""
# Act
with patch("builtins.open", mock_open(read_data=mock_requirements_content)):
version = FrontendManager.get_required_frontend_version()
# Assert
assert version is None
def test_get_templates_version():
# Arrange
expected_version = "0.1.41"
mock_requirements_content = """torch
torchsde
comfyui-frontend-package==1.25.0
comfyui-workflow-templates==0.1.41
other-package==1.0.0
numpy"""
# Act
with patch("builtins.open", mock_open(read_data=mock_requirements_content)):
version = FrontendManager.get_required_templates_version()
# Assert
assert version == expected_version
def test_get_templates_version_not_found():
# Arrange
mock_requirements_content = """torch
torchsde
comfyui-frontend-package==1.25.0
other-package==1.0.0
numpy"""
# Act
with patch("builtins.open", mock_open(read_data=mock_requirements_content)):
version = FrontendManager.get_required_templates_version()
# Assert
assert version is None
def test_get_templates_version_invalid_semver():
# Arrange
mock_requirements_content = """torch
torchsde
comfyui-workflow-templates==1.0.0.beta
other-package==1.0.0
numpy"""
# Act
with patch("builtins.open", mock_open(read_data=mock_requirements_content)):
version = FrontendManager.get_required_templates_version()
# Assert
assert version is None
def test_get_installed_templates_version():
# Arrange
expected_version = "0.1.40"
# Act
with patch("app.frontend_management.version", return_value=expected_version):
version = FrontendManager.get_installed_templates_version()
# Assert
assert version == expected_version
def test_get_installed_templates_version_not_installed():
# Act
with patch(
"app.frontend_management.version", side_effect=Exception("Package not found")
):
version = FrontendManager.get_installed_templates_version()
# Assert
assert version is None
# ---------------------------------------------------------------------------
# Auto-managed @latest / @prerelease cleanup (CORE-285)
# ---------------------------------------------------------------------------
@pytest.fixture
def custom_frontends_root(tmp_path, monkeypatch):
"""Point ``FrontendManager.CUSTOM_FRONTENDS_ROOT`` at a fresh tmp dir."""
root = tmp_path / "web_custom_versions"
root.mkdir()
monkeypatch.setattr(FrontendManager, "CUSTOM_FRONTENDS_ROOT", str(root))
return root
def _make_version_dir(root, owner, repo, version):
"""Create ``<root>/<owner>_<repo>/<version>/index.html`` and return path."""
folder = root / f"{owner}_{repo}" / version
folder.mkdir(parents=True, exist_ok=True)
(folder / "index.html").write_text("<html></html>")
return folder
def test_auto_managed_metadata_roundtrip(custom_frontends_root):
FrontendManager._write_auto_managed_versions("o", "r", ["1.0.0", "1.1.0", "1.0.0"])
assert FrontendManager._read_auto_managed_versions("o", "r") == ["1.0.0", "1.1.0"]
def test_read_auto_managed_versions_missing(custom_frontends_root):
assert FrontendManager._read_auto_managed_versions("o", "r") == []
def test_read_auto_managed_versions_corrupt(custom_frontends_root):
provider_dir = custom_frontends_root / "o_r"
provider_dir.mkdir()
(provider_dir / FrontendManager.AUTO_MANAGED_METADATA_FILENAME).write_text(
"not json"
)
assert FrontendManager._read_auto_managed_versions("o", "r") == []
@pytest.mark.parametrize("payload", ["[]", "null", '"oops"', "42"])
def test_read_auto_managed_versions_non_dict_root(custom_frontends_root, payload):
"""Valid JSON whose root isn't a dict must not raise — bug pointed out in
Oracle review: ``data.get(...)`` would throw on non-dict roots."""
provider_dir = custom_frontends_root / "o_r"
provider_dir.mkdir()
(provider_dir / FrontendManager.AUTO_MANAGED_METADATA_FILENAME).write_text(payload)
assert FrontendManager._read_auto_managed_versions("o", "r") == []
def test_read_auto_managed_versions_drops_unsafe_names(custom_frontends_root):
"""Tampered metadata containing path-traversal-y names must be ignored
at read time so cleanup never even sees them."""
provider_dir = custom_frontends_root / "o_r"
provider_dir.mkdir()
(provider_dir / FrontendManager.AUTO_MANAGED_METADATA_FILENAME).write_text(
'{"auto_managed": ["1.0.0", "../escape", "/etc", "..", ".", "ok-1.2"]}'
)
assert FrontendManager._read_auto_managed_versions("o", "r") == ["1.0.0", "ok-1.2"]
def test_write_auto_managed_versions_drops_unsafe_names(custom_frontends_root):
"""Even if a caller passes a tainted list, the file we persist must
only contain safe names."""
FrontendManager._write_auto_managed_versions(
"o", "r", ["1.0.0", "../escape", "/etc/passwd", "..", "."]
)
assert FrontendManager._read_auto_managed_versions("o", "r") == ["1.0.0"]
def test_prune_refuses_to_delete_outside_provider_dir(
custom_frontends_root, monkeypatch
):
"""Defense in depth: even if a hostile name slips past the read/write
filters somehow, ``_prune_auto_managed_versions`` must refuse to rmtree
anything outside the provider directory."""
# Set up a sibling directory that must NOT be touched.
sibling = custom_frontends_root / "do-not-touch"
sibling.mkdir()
(sibling / "marker").write_text("keep me")
provider_dir = custom_frontends_root / "o_r"
provider_dir.mkdir()
_make_version_dir(custom_frontends_root, "o", "r", "1.0.0")
# Bypass the read filter to simulate a corrupt-but-parseable list slipping
# through (e.g. older code wrote it before this hardening landed).
monkeypatch.setattr(
FrontendManager,
"_read_auto_managed_versions",
classmethod(lambda cls, owner, repo: ["1.0.0", "../do-not-touch"]),
)
FrontendManager._prune_auto_managed_versions("o", "r", keep_version="2.0.0")
# Sibling untouched.
assert sibling.exists()
assert (sibling / "marker").read_text() == "keep me"
# In-bounds folder still gets cleaned.
assert not (provider_dir / "1.0.0").exists()
def test_prune_auto_managed_versions_removes_stale_and_keeps_pinned(
custom_frontends_root,
):
# Two versions previously fetched via @latest, plus an explicitly pinned one.
_make_version_dir(custom_frontends_root, "o", "r", "1.0.0")
_make_version_dir(custom_frontends_root, "o", "r", "1.1.0")
pinned = _make_version_dir(custom_frontends_root, "o", "r", "1.2.0")
FrontendManager._write_auto_managed_versions("o", "r", ["1.0.0", "1.1.0"])
# User runs @latest again and it resolves to 1.1.0 — older auto-managed
# 1.0.0 should be deleted, pinned 1.2.0 should remain untouched.
FrontendManager._prune_auto_managed_versions("o", "r", keep_version="1.1.0")
provider_dir = custom_frontends_root / "o_r"
assert not (provider_dir / "1.0.0").exists()
assert (provider_dir / "1.1.0").exists()
assert pinned.exists()
assert FrontendManager._read_auto_managed_versions("o", "r") == ["1.1.0"]
def test_untrack_auto_managed_version_does_not_delete_folder(custom_frontends_root):
version_dir = _make_version_dir(custom_frontends_root, "o", "r", "1.0.0")
FrontendManager._write_auto_managed_versions("o", "r", ["1.0.0", "1.1.0"])
FrontendManager._untrack_auto_managed_version("o", "r", "1.0.0")
assert version_dir.exists()
assert FrontendManager._read_auto_managed_versions("o", "r") == ["1.1.0"]
def test_init_frontend_latest_prunes_previous_auto_managed_versions(
custom_frontends_root, mock_provider, mock_releases
):
# Pre-existing folders: 1.0.0 was previously downloaded via @latest, 1.1.5
# was explicitly pinned by the user. Now @latest resolves to 2.0.0.
_make_version_dir(custom_frontends_root, "test-owner", "test-repo", "1.0.0")
pinned = _make_version_dir(
custom_frontends_root, "test-owner", "test-repo", "1.1.5"
)
FrontendManager._write_auto_managed_versions(
"test-owner", "test-repo", ["1.0.0"]
)
# Stub out the actual download so we just create the destination dir.
def fake_download(release, destination_path):
Path(destination_path).mkdir(parents=True, exist_ok=True)
(Path(destination_path) / "index.html").write_text("<html></html>")
with patch(
"app.frontend_management.download_release_asset_zip",
side_effect=fake_download,
):
result = FrontendManager.init_frontend_unsafe(
"test-owner/test-repo@latest", mock_provider
)
provider_dir = custom_frontends_root / "test-owner_test-repo"
# 2.0.0 was downloaded and tracked.
assert Path(result) == provider_dir / "2.0.0"
assert (provider_dir / "2.0.0").exists()
assert FrontendManager._read_auto_managed_versions(
"test-owner", "test-repo"
) == ["2.0.0"]
# Old auto-managed 1.0.0 was pruned.
assert not (provider_dir / "1.0.0").exists()
# Pinned 1.1.5 was left alone.
assert pinned.exists()
def test_init_frontend_explicit_version_promotes_out_of_auto_managed(
custom_frontends_root, mock_provider
):
# 1.0.0 was previously downloaded via @latest.
_make_version_dir(custom_frontends_root, "test-owner", "test-repo", "1.0.0")
FrontendManager._write_auto_managed_versions(
"test-owner", "test-repo", ["1.0.0"]
)
# User now explicitly pins it. The `v`-prefixed early-return path runs.
result = FrontendManager.init_frontend_unsafe(
"test-owner/test-repo@v1.0.0", mock_provider
)
provider_dir = custom_frontends_root / "test-owner_test-repo"
assert Path(result) == provider_dir / "1.0.0"
# It should no longer be tracked as auto-managed, so a future @latest run
# won't sweep it away.
assert FrontendManager._read_auto_managed_versions(
"test-owner", "test-repo"
) == []
# The folder is still on disk.
assert (provider_dir / "1.0.0").exists()
def test_init_frontend_explicit_version_no_v_prefix_promotes_out_of_auto_managed(
custom_frontends_root, mock_provider
):
# 1.0.0 was previously downloaded via @latest, and is also already on
# disk so the GitHub-resolution path is a no-op for download.
_make_version_dir(custom_frontends_root, "test-owner", "test-repo", "1.0.0")
FrontendManager._write_auto_managed_versions(
"test-owner", "test-repo", ["1.0.0"]
)
# No `v` prefix → goes through the GitHub release lookup path. The folder
# already exists, so download is skipped.
with patch(
"app.frontend_management.download_release_asset_zip"
) as mock_download_zip:
FrontendManager.init_frontend_unsafe(
"test-owner/test-repo@1.0.0", mock_provider
)
mock_download_zip.assert_not_called()
# It should be promoted out of auto-managed even when the folder is reused.
assert FrontendManager._read_auto_managed_versions(
"test-owner", "test-repo"
) == []