mirror of
https://github.com/Comfy-Org/ComfyUI-Manager.git
synced 2026-07-27 02:27:30 +08:00
feat(security): dedicated install flags decouple git_url/pip from security_level
Add two boolean config.ini [default] flags — allow_git_url_install and allow_pip_install (both default false) — that fully REPLACE the security_level term on the legacy install surfaces: - POST /v2/customnode/install/git_url (S-A) and POST /v2/customnode/install/pip (S-B) are now gated solely by their dedicated flag AND the retained network-position invariant (loopback listener OR network_mode=personal_cloud). security_level no longer affects these two surfaces in either direction. - The batch unknown-URL branch (S-C) routes through the same predicate; the unknown-pip branch stays unconditionally blocked; the general middle+ batch entry gate is unchanged. - New pure predicate is_dedicated_install_allowed() in common/manager_security.py (config-import-free; callers pass values from their own reader). Both config readers (glob + legacy) register the keys in read/write/fallback paths. - Denial logs and frontend copy name the responsible flag instead of the misleading security_level guidance. Public listeners remain denied regardless of the flags (no exposure widening). - README security policy updated: config keys documented, git-url/pip removed from the security_level risky table, and a dedicated-flags subsection (REPLACE semantics, network rule, batch behavior, restart-only activation, weak/normal- opt-in migration note). - Migration: existing weak/normal- users must opt in via the new flags (CHANGELOG note; deliberate no auto-seed). Includes the unit/config/guard test suites (88 tests): predicate truth table, dual-reader config contract (missing/malformed keys read false, round-trip, cache staleness), security_level-matrix freeze guards, and suite-order-independent test stubs.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
"""Shared test-support for the goal265 dedicated-install-flag test modules.
|
||||
|
||||
NOT a test module (no ``test_`` prefix — pytest does not collect it).
|
||||
|
||||
Provides minimal runtime stubs so ``comfyui_manager`` package modules can be
|
||||
imported in a plain dev venv (outside a real ComfyUI runtime):
|
||||
|
||||
- ``comfy.cli_args.args`` — consumed by ``comfyui_manager/__init__.py`` at
|
||||
package import time (``from comfy.cli_args import args``). The stub pins
|
||||
``listen='127.0.0.1'`` (loopback) which matches the default precondition of
|
||||
every scenario row (goal265-scenarios.md §0 preamble).
|
||||
- ``folder_paths`` — consumed by ``comfyui_manager/common/context.py`` at
|
||||
import time to derive the manager user directory. The stub redirects it to
|
||||
a throwaway temp dir so importing the package NEVER creates directories
|
||||
inside the repository checkout.
|
||||
|
||||
SUITE-ORDER INDEPENDENCE (goal265 FU, task #293): every public ``import_*``
|
||||
entry point is self-sufficient — it repairs whatever ``sys.modules`` state
|
||||
earlier test modules left behind, instead of assuming a pristine interpreter:
|
||||
|
||||
- Other test modules legitimately install FAKE ``comfyui_manager`` lineage
|
||||
entries at module level (e.g. tests/test_unified_dep_resolver.py:63-73
|
||||
registers a plain ``types.ModuleType("comfyui_manager")`` — NOT a package,
|
||||
no ``__path__`` — to host a file-loaded module under a dotted name). Those
|
||||
modules are imported at pytest COLLECTION time, so under full-suite
|
||||
ordering the fake is already in ``sys.modules`` before any fixture here
|
||||
runs, and ``import comfyui_manager.glob.manager_core`` dies with
|
||||
``ModuleNotFoundError: 'comfyui_manager' is not a package``.
|
||||
- ``_purge_fake_comfyui_manager()`` therefore validates the cached lineage
|
||||
against the REAL package directory on disk and drops fake/broken entries
|
||||
before importing. Dropping ``sys.modules`` entries is safe for the other
|
||||
test modules: they hold direct object references to what they loaded;
|
||||
they do not re-resolve through ``sys.modules``.
|
||||
- ``ensure_comfy_stubs()`` likewise repairs half-formed ``comfy`` /
|
||||
``folder_paths`` entries (present but missing the attributes we need)
|
||||
rather than only handling the absent case.
|
||||
|
||||
Used by:
|
||||
- tests/test_install_flags_config.py (dual-reader config tests)
|
||||
- tests/test_install_flags_guards.py (out-of-scope guard tests)
|
||||
|
||||
Spec SoT: ~/.claude/pair-cowork/scratch/gm-team/goal265-spec.md §4
|
||||
(test surface contract — binding for Step-4 test authoring).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
#: repo root (parent of tests/)
|
||||
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
#: the real on-disk package directory the cached lineage must resolve to
|
||||
_REAL_PKG_DIR = os.path.join(REPO_ROOT, "comfyui_manager")
|
||||
|
||||
|
||||
def ensure_comfy_stubs() -> None:
|
||||
"""Install or REPAIR minimal ``comfy``/``folder_paths`` stubs (idempotent).
|
||||
|
||||
Handles three prior states per module:
|
||||
- absent -> install a fresh stub
|
||||
- present but inadequate -> patch the missing attributes in place
|
||||
(another test's stub, or a partially-initialized real module)
|
||||
- present and adequate -> leave untouched (e.g. real ComfyUI runtime)
|
||||
"""
|
||||
# --- comfy.cli_args.args ------------------------------------------------
|
||||
comfy = sys.modules.get("comfy")
|
||||
if comfy is None:
|
||||
comfy = types.ModuleType("comfy")
|
||||
sys.modules["comfy"] = comfy
|
||||
|
||||
cli_args = sys.modules.get("comfy.cli_args")
|
||||
if cli_args is None:
|
||||
cli_args = getattr(comfy, "cli_args", None)
|
||||
if not isinstance(cli_args, types.ModuleType):
|
||||
cli_args = types.ModuleType("comfy.cli_args")
|
||||
sys.modules["comfy.cli_args"] = cli_args
|
||||
|
||||
args = getattr(cli_args, "args", None)
|
||||
if args is None or not hasattr(args, "listen"):
|
||||
setattr(cli_args, "args", types.SimpleNamespace(
|
||||
listen="127.0.0.1",
|
||||
enable_manager=True,
|
||||
enable_manager_legacy_ui=False,
|
||||
))
|
||||
# re-link parent attr idempotently (a bare ``comfy`` stub from another
|
||||
# test may lack the submodule attribute even when both entries exist)
|
||||
setattr(comfy, "cli_args", cli_args)
|
||||
|
||||
# --- folder_paths -------------------------------------------------------
|
||||
fp = sys.modules.get("folder_paths")
|
||||
if fp is None:
|
||||
fp = types.ModuleType("folder_paths")
|
||||
sys.modules["folder_paths"] = fp
|
||||
if not hasattr(fp, "get_system_user_directory"):
|
||||
base = tempfile.mkdtemp(prefix="cm-flags-test-user-")
|
||||
setattr(fp, "get_system_user_directory",
|
||||
lambda name: os.path.join(base, name))
|
||||
|
||||
|
||||
def _module_is_real(mod: types.ModuleType) -> bool:
|
||||
"""True when a cached ``comfyui_manager*`` module resolves to the real
|
||||
on-disk package tree (by ``__path__`` for packages, ``__file__`` for
|
||||
leaf modules)."""
|
||||
real_root = os.path.abspath(_REAL_PKG_DIR)
|
||||
paths = getattr(mod, "__path__", None)
|
||||
if paths is not None:
|
||||
return any(os.path.abspath(p).startswith(real_root) for p in paths)
|
||||
file = getattr(mod, "__file__", None)
|
||||
if file is not None:
|
||||
return os.path.abspath(file).startswith(real_root)
|
||||
# neither __path__ nor __file__: a bare ModuleType fake
|
||||
return False
|
||||
|
||||
|
||||
def _purge_fake_comfyui_manager() -> None:
|
||||
"""Drop fake/broken ``comfyui_manager`` lineage entries from
|
||||
``sys.modules`` so a subsequent real-package import succeeds.
|
||||
|
||||
Uniform rule — an entry is dropped iff it does NOT resolve to the real
|
||||
on-disk package tree:
|
||||
|
||||
- A fake top-level entry (bare ``ModuleType`` without ``__path__`` —
|
||||
'not a package') is dropped so the real package can import.
|
||||
- Fake SUBentries injected under dotted names (e.g.
|
||||
``comfyui_manager.common.manager_util`` replaced by a stub) are
|
||||
dropped; the import machinery re-imports the real module on next
|
||||
access.
|
||||
- REAL-file modules registered under dotted names by other test
|
||||
modules (e.g. ``comfyui_manager.common.unified_dep_resolver`` loaded
|
||||
via spec_from_file_location by tests/test_unified_dep_resolver.py)
|
||||
are KEPT: those tests later resolve the SAME cached entry through
|
||||
``mock.patch("comfyui_manager.common.unified_dep_resolver.X")``;
|
||||
evicting it would make mock.patch import a fresh second instance and
|
||||
silently patch the wrong module object.
|
||||
"""
|
||||
lineage = [n for n in list(sys.modules)
|
||||
if n == "comfyui_manager" or n.startswith("comfyui_manager.")]
|
||||
for name in lineage:
|
||||
mod = sys.modules.get(name)
|
||||
if mod is None or not _module_is_real(mod):
|
||||
del sys.modules[name]
|
||||
|
||||
|
||||
def _import_real(dotted: str):
|
||||
"""ensure stubs -> purge fakes -> import; shared by all entry points."""
|
||||
ensure_comfy_stubs()
|
||||
_purge_fake_comfyui_manager()
|
||||
return importlib.import_module(dotted)
|
||||
|
||||
|
||||
def import_reader(reader: str):
|
||||
"""Import and return ``comfyui_manager.<reader>.manager_core``.
|
||||
|
||||
``reader`` is ``"glob"`` or ``"legacy"`` — the two independent config
|
||||
reader implementations sharing one config.ini (dual-reader rule,
|
||||
goal265-mm.md §1.1 / goal265-spec.md §3).
|
||||
"""
|
||||
assert reader in ("glob", "legacy"), reader
|
||||
return _import_real(f"comfyui_manager.{reader}.manager_core")
|
||||
|
||||
|
||||
def import_context():
|
||||
"""Import and return ``comfyui_manager.common.context`` (holds
|
||||
``manager_config_path``, consumed by both readers at call time)."""
|
||||
return _import_real("comfyui_manager.common.context")
|
||||
|
||||
|
||||
def import_glob_security_utils():
|
||||
"""Import and return ``comfyui_manager.glob.utils.security_utils``
|
||||
(the glob copy of the security_level gate matrix — guard rows
|
||||
SC-25C / SC-28)."""
|
||||
return _import_real("comfyui_manager.glob.utils.security_utils")
|
||||
@@ -0,0 +1,267 @@
|
||||
"""[goal265 step4 — RED] Predicate truth table for ``is_dedicated_install_allowed``.
|
||||
|
||||
TARGET CONTRACT (NOT YET IMPLEMENTED — goal265-spec.md §1.2, LOCKED):
|
||||
|
||||
comfyui_manager/common/manager_security.py::is_dedicated_install_allowed(
|
||||
flag_value: bool, listen_address: str, network_mode: str) -> bool
|
||||
|
||||
P-direct: allowed iff bool(flag_value)
|
||||
AND (is_loopback(listen_address)
|
||||
OR network_mode.lower() == 'personal_cloud')
|
||||
|
||||
These tests are EXPECTED TO FAIL/ERROR against current code (the predicate
|
||||
does not exist yet) — RED confirmation is goal265 Step 5. Do NOT weaken them
|
||||
to pass against today's code.
|
||||
|
||||
SC rows covered (goal265-scenarios.md — predicate-level arm):
|
||||
SC-01, SC-02, SC-03, SC-05, SC-06, SC-07 ([D1] allow_git_url_install)
|
||||
SC-11, SC-12, SC-13, SC-14, SC-15 ([D2] allow_pip_install)
|
||||
SC-16, SC-17 (flag independence, [D1][D2])
|
||||
|
||||
security_level is ABSENT from the predicate signature — its irrelevance is
|
||||
proven BY CONSTRUCTION (spec §4 row 1): rows whose preconditions differ only
|
||||
in security_level (SC-01 vs SC-02; SC-06/SC-14 parametrizations) map onto the
|
||||
IDENTICAL predicate call, so no security_level value can change the outcome.
|
||||
|
||||
Fixtures: none — pure function (spec §4 binding patching constraint).
|
||||
The module under test is loaded directly by file path so the test does not
|
||||
import the ``comfyui_manager`` package (whose __init__ needs the ComfyUI
|
||||
runtime); ``manager_security.py`` itself is dependency-light by design
|
||||
(spec §1.2: MUST stay config-import-free).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import inspect
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
_MANAGER_SECURITY_PATH = (
|
||||
pathlib.Path(__file__).resolve().parents[2]
|
||||
/ "comfyui_manager" / "common" / "manager_security.py"
|
||||
)
|
||||
|
||||
# Address / network-mode vocabulary (scenarios §0 preamble):
|
||||
LOOPBACK = "127.0.0.1"
|
||||
LOOPBACK_V6 = "::1"
|
||||
PUBLIC_ADDR = "203.0.113.5" # TEST-NET-3 — non-loopback ("listen=public")
|
||||
NM_PUBLIC = "public"
|
||||
NM_PERSONAL_CLOUD = "personal_cloud"
|
||||
|
||||
# security_level context values for irrelevance-by-construction params.
|
||||
# The predicate takes NO security_level argument; these ids document which
|
||||
# scenario-row context each identical call stands for.
|
||||
SECURITY_LEVELS = ("strong", "normal", "normal-", "weak")
|
||||
|
||||
|
||||
def _load_manager_security():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_manager_security_under_test", _MANAGER_SECURITY_PATH
|
||||
)
|
||||
assert spec is not None and spec.loader is not None, _MANAGER_SECURITY_PATH
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def predicate():
|
||||
"""The predicate under test. FAILS (RED) while the function is absent."""
|
||||
mod = _load_manager_security()
|
||||
assert hasattr(mod, "is_dedicated_install_allowed"), (
|
||||
"RED: comfyui_manager/common/manager_security.py does not define "
|
||||
"is_dedicated_install_allowed yet (goal265-spec.md §1.2). "
|
||||
"This is the expected state before Step 6 (Develop)."
|
||||
)
|
||||
return mod.is_dedicated_install_allowed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Structural: security_level is not even an input (irrelevance by construction)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_signature_has_no_security_level_parameter(predicate):
|
||||
"""[SC-01..SC-17 foundation] Spec §1.2 locks the signature to exactly
|
||||
(flag_value, listen_address, network_mode) — security_level CANNOT
|
||||
influence the decision because it is not an input."""
|
||||
params = list(inspect.signature(predicate).parameters)
|
||||
assert params == ["flag_value", "listen_address", "network_mode"], (
|
||||
f"Locked signature drifted: {params!r} "
|
||||
"(goal265-spec.md §1.2 — security_level must NOT appear)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [D1] allow_git_url_install — happy paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sc01_flag_true_loopback_allows_under_strong_security_level(predicate):
|
||||
"""SC-01: git=true, sl=strong, listen=loopback, nm=public -> allowed.
|
||||
sl=strong is context only — the call has no security_level input."""
|
||||
assert predicate(True, LOOPBACK, NM_PUBLIC) is True
|
||||
|
||||
|
||||
def test_sc02_flag_true_loopback_allows_under_default_security_level(predicate):
|
||||
"""SC-02: git=true, sl=normal (default), listen=loopback, nm=public ->
|
||||
allowed. Identical call to SC-01: proves sl irrelevance by construction."""
|
||||
assert predicate(True, LOOPBACK, NM_PUBLIC) is True
|
||||
|
||||
|
||||
def test_sc03_flag_true_public_listener_personal_cloud_allows(predicate):
|
||||
"""SC-03: git=true, sl=normal, listen=public, nm=personal_cloud ->
|
||||
allowed (personal_cloud arm of the network-position invariant)."""
|
||||
assert predicate(True, PUBLIC_ADDR, NM_PERSONAL_CLOUD) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [D1] allow_git_url_install — failure paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sc05_flag_false_denies_even_at_weak_security_level(predicate):
|
||||
"""SC-05: git=false, sl=weak, listen=loopback -> denied. Deny-direction
|
||||
proof of security_level irrelevance (today sl=weak would ALLOW)."""
|
||||
assert predicate(False, LOOPBACK, NM_PUBLIC) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("security_level_context", SECURITY_LEVELS[:3],
|
||||
ids=[f"sl={s}" for s in SECURITY_LEVELS[:3]])
|
||||
def test_sc06_flag_false_denies_for_every_security_level(
|
||||
predicate, security_level_context):
|
||||
"""SC-06: git=false, sl in {strong, normal, normal-}, listen=loopback ->
|
||||
denied for EVERY sl value. The parametrize axis documents that all three
|
||||
scenario contexts collapse onto the same flag-only call — the flag is
|
||||
the sole decider."""
|
||||
assert predicate(False, LOOPBACK, NM_PUBLIC) is False
|
||||
|
||||
|
||||
def test_sc07_flag_true_cannot_open_public_listener(predicate):
|
||||
"""SC-07: git=true, sl=weak, listen=public, nm=public -> denied.
|
||||
Network-position invariant retained (spec §1.1 invariant 2): the flag
|
||||
must never widen exposure beyond what is possible today."""
|
||||
assert predicate(True, PUBLIC_ADDR, NM_PUBLIC) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [D2] allow_pip_install — happy paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sc11_pip_flag_true_loopback_allows_under_strong(predicate):
|
||||
"""SC-11: pip=true, sl=strong, listen=loopback, nm=public -> allowed."""
|
||||
assert predicate(True, LOOPBACK, NM_PUBLIC) is True
|
||||
|
||||
|
||||
def test_sc12_pip_flag_true_public_listener_personal_cloud_allows(predicate):
|
||||
"""SC-12: pip=true, sl=normal, listen=public, nm=personal_cloud ->
|
||||
allowed."""
|
||||
assert predicate(True, PUBLIC_ADDR, NM_PERSONAL_CLOUD) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [D2] allow_pip_install — failure paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sc13_pip_flag_false_denies_even_at_weak(predicate):
|
||||
"""SC-13: pip=false, sl=weak, listen=loopback -> denied."""
|
||||
assert predicate(False, LOOPBACK, NM_PUBLIC) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("security_level_context", SECURITY_LEVELS[:3],
|
||||
ids=[f"sl={s}" for s in SECURITY_LEVELS[:3]])
|
||||
def test_sc14_pip_flag_false_denies_for_every_security_level(
|
||||
predicate, security_level_context):
|
||||
"""SC-14: pip=false, sl in {strong, normal, normal-}, listen=loopback ->
|
||||
denied each. Same irrelevance-by-construction pattern as SC-06."""
|
||||
assert predicate(False, LOOPBACK, NM_PUBLIC) is False
|
||||
|
||||
|
||||
def test_sc15_pip_flag_true_cannot_open_public_listener(predicate):
|
||||
"""SC-15: pip=true, sl=weak, listen=public, nm=public -> denied
|
||||
(network invariant)."""
|
||||
assert predicate(True, PUBLIC_ADDR, NM_PUBLIC) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flag independence (cross-link [D1]+[D2])
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sc16_git_false_pip_true_independent_outcomes(predicate):
|
||||
"""SC-16: git=false, pip=true, sl=normal, listen=loopback -> git denied,
|
||||
pip allowed. At predicate level each surface passes ONLY its own flag —
|
||||
same network inputs, opposite outcomes."""
|
||||
git_allowed = predicate(False, LOOPBACK, NM_PUBLIC)
|
||||
pip_allowed = predicate(True, LOOPBACK, NM_PUBLIC)
|
||||
assert git_allowed is False
|
||||
assert pip_allowed is True
|
||||
|
||||
|
||||
def test_sc17_git_true_pip_false_independent_outcomes(predicate):
|
||||
"""SC-17: git=true, pip=false, sl=normal, listen=loopback -> git allowed,
|
||||
pip denied (mirror of SC-16)."""
|
||||
git_allowed = predicate(True, LOOPBACK, NM_PUBLIC)
|
||||
pip_allowed = predicate(False, LOOPBACK, NM_PUBLIC)
|
||||
assert git_allowed is True
|
||||
assert pip_allowed is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supplementary: exhaustive flag x loopback x personal_cloud truth table
|
||||
# (spec §4 row 1 — "flag x loopback x personal_cloud" full table)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("flag", "listen", "network_mode", "expected"),
|
||||
[
|
||||
(True, LOOPBACK, NM_PUBLIC, True), # SC-01/02/11
|
||||
(True, LOOPBACK, NM_PERSONAL_CLOUD, True), # both arms true
|
||||
(True, PUBLIC_ADDR, NM_PERSONAL_CLOUD, True), # SC-03/12
|
||||
(True, PUBLIC_ADDR, NM_PUBLIC, False), # SC-07/15
|
||||
(False, LOOPBACK, NM_PUBLIC, False), # SC-05/06/13/14
|
||||
(False, LOOPBACK, NM_PERSONAL_CLOUD, False),
|
||||
(False, PUBLIC_ADDR, NM_PERSONAL_CLOUD, False),
|
||||
(False, PUBLIC_ADDR, NM_PUBLIC, False),
|
||||
],
|
||||
ids=[
|
||||
"flag+loopback+nm_public",
|
||||
"flag+loopback+personal_cloud",
|
||||
"flag+public+personal_cloud",
|
||||
"flag+public+nm_public",
|
||||
"noflag+loopback+nm_public",
|
||||
"noflag+loopback+personal_cloud",
|
||||
"noflag+public+personal_cloud",
|
||||
"noflag+public+nm_public",
|
||||
],
|
||||
)
|
||||
def test_truth_table_flag_x_loopback_x_personal_cloud(
|
||||
predicate, flag, listen, network_mode, expected):
|
||||
"""Full 2x2x2 truth table for P-direct (spec §1.1): allowed iff flag AND
|
||||
(loopback OR personal_cloud). Consolidates SC-01/02/03/05/06/07 ([D1])
|
||||
and SC-11/12/13/14/15 ([D2]) plus the two combinations no single row
|
||||
pins (flag+loopback+personal_cloud, noflag+public+personal_cloud)."""
|
||||
assert predicate(flag, listen, network_mode) is expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supplementary edge handling locked by the spec text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ipv6_loopback_counts_as_loopback(predicate):
|
||||
"""SC-01/SC-02 edge (loopback arm): `is_loopback` is ipaddress-based
|
||||
(manager_security.py) — ::1 must be treated as loopback too."""
|
||||
assert predicate(True, LOOPBACK_V6, NM_PUBLIC) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("network_mode", ["Personal_Cloud", "PERSONAL_CLOUD"],
|
||||
ids=["mixed-case", "upper-case"])
|
||||
def test_network_mode_personal_cloud_is_case_insensitive(predicate, network_mode):
|
||||
"""SC-03/SC-12 edge (personal_cloud arm): spec §1.2 predicate body —
|
||||
network_mode.lower() == 'personal_cloud' (case-insensitive)."""
|
||||
assert predicate(True, PUBLIC_ADDR, network_mode) is True
|
||||
|
||||
|
||||
def test_unparseable_listen_address_is_not_loopback(predicate):
|
||||
"""SC-07/SC-15 edge (network invariant): is_loopback returns False for
|
||||
unparseable addresses (ValueError arm) — the predicate must fail CLOSED
|
||||
on a malformed listen address."""
|
||||
assert predicate(True, "not-an-ip-address", NM_PUBLIC) is False
|
||||
@@ -0,0 +1,268 @@
|
||||
"""[goal265 step4 — RED] Dual-reader config contract for the dedicated
|
||||
install flags ``allow_git_url_install`` / ``allow_pip_install``.
|
||||
|
||||
TARGET CONTRACT (NOT YET IMPLEMENTED — goal265-spec.md §3, LOCKED):
|
||||
|
||||
- Keys: ``allow_git_url_install``, ``allow_pip_install`` in
|
||||
config.ini ``[default]``.
|
||||
- BOTH readers carry the keys (read dict + write_config list +
|
||||
exception-fallback dict — 3 anchors each):
|
||||
* ``comfyui_manager/glob/manager_core.py``
|
||||
* ``comfyui_manager/legacy/manager_core.py``
|
||||
(dual-reader rule — the gated endpoints resolve through the LEGACY
|
||||
reader; a glob-only registration would silently never reach the gates.)
|
||||
- Only case-insensitive string "true" is truthy on read; anything else
|
||||
(including "1", "yes") reads False.
|
||||
- Missing key reads False — the shared ``get_bool(key, default)`` quirk:
|
||||
the ``default`` param is IGNORED, missing key -> always False.
|
||||
- Missing file / missing [default] section -> exception-fallback dict
|
||||
returns False for both keys.
|
||||
- Activation is restart-only (module-level ``cached_config``).
|
||||
|
||||
These tests are EXPECTED TO FAIL against current code (the keys are not
|
||||
registered in either reader yet) — RED confirmation is goal265 Step 5.
|
||||
|
||||
SC rows covered (goal265-scenarios.md):
|
||||
SC-19 missing keys -> False (both readers)
|
||||
SC-20 malformed values -> only "true"/"TRUE"/"True" truthy (both readers)
|
||||
SC-21 write_config round-trip persistence (both readers)
|
||||
SC-22 cached_config staleness — restart-only activation (reader-level arm)
|
||||
SC-29 no config.ini / no [default] section -> fallback False (both readers)
|
||||
|
||||
Fixtures (spec §4 binding): tmp config.ini + monkeypatch
|
||||
``context.manager_config_path`` + per-reader ``cached_config`` reset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _install_flags_testutil import import_context, import_reader # noqa: E402
|
||||
|
||||
FLAG_KEYS = ("allow_git_url_install", "allow_pip_install")
|
||||
READERS = ("glob", "legacy")
|
||||
|
||||
|
||||
def _reset_cache(core) -> None:
|
||||
"""Clear the reader's module-level cached_config (simulates restart)."""
|
||||
setattr(core, "cached_config", None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(params=READERS)
|
||||
def reader_core(request):
|
||||
"""Yield (reader_name, manager_core module) for BOTH readers, with the
|
||||
module-level cached_config saved/cleared around each test so one test's
|
||||
cache never leaks into another (or into other test modules)."""
|
||||
core = import_reader(request.param)
|
||||
saved = getattr(core, "cached_config", None)
|
||||
_reset_cache(core)
|
||||
yield request.param, core
|
||||
setattr(core, "cached_config", saved)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def point_config(monkeypatch):
|
||||
"""Return a function that points context.manager_config_path at a path.
|
||||
|
||||
Both readers resolve ``context.manager_config_path`` at CALL time
|
||||
(``config.read(context.manager_config_path)``), so monkeypatching the
|
||||
context attribute redirects glob AND legacy alike."""
|
||||
ctx = import_context()
|
||||
|
||||
def _point(path):
|
||||
monkeypatch.setattr(ctx, "manager_config_path", str(path))
|
||||
|
||||
return _point
|
||||
|
||||
|
||||
def _write_ini(tmp_path, body: str):
|
||||
ini = tmp_path / "config.ini"
|
||||
ini.write_text(body, encoding="utf-8")
|
||||
return ini
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SC-19 — missing keys read False (get_bool quirk: missing key -> False)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sc19_missing_keys_read_false(reader_core, point_config, tmp_path):
|
||||
"""SC-19: config.ini WITHOUT either new key -> read_config returns
|
||||
allow_git_url_install == False AND allow_pip_install == False.
|
||||
|
||||
This is the observable manifestation of the shared ``get_bool(key,
|
||||
default)`` quirk (spec §3): the ``default`` parameter is IGNORED —
|
||||
a missing key always reads False, never the passed default. The quirk
|
||||
is documented, NOT fixed, by this GOAL (spec §5 freeze item 9)."""
|
||||
reader, core = reader_core
|
||||
point_config(_write_ini(tmp_path, "[default]\nsecurity_level = normal\n"))
|
||||
|
||||
cfg = core.read_config()
|
||||
|
||||
for key in FLAG_KEYS:
|
||||
assert key in cfg, (
|
||||
f"RED: {reader} read_config() does not register {key!r} "
|
||||
"(goal265-spec.md §3 reader registration)"
|
||||
)
|
||||
assert cfg[key] is False, f"{reader}: missing {key} must read False"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SC-20 — malformed values: only case-insensitive "true" is truthy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_value", "expected"),
|
||||
[
|
||||
("true", True),
|
||||
("TRUE", True),
|
||||
("True", True),
|
||||
("1", False),
|
||||
("yes", False),
|
||||
("false", False),
|
||||
("garbage", False),
|
||||
],
|
||||
ids=["true", "TRUE", "True", "1", "yes", "false", "garbage"],
|
||||
)
|
||||
@pytest.mark.parametrize("flag_key", FLAG_KEYS)
|
||||
def test_sc20_only_case_insensitive_true_is_truthy(
|
||||
reader_core, point_config, tmp_path, flag_key, raw_value, expected):
|
||||
"""SC-20: key present with malformed values -> only case-insensitive
|
||||
"true" reads True; "1"/"yes"/"false"/"garbage" read False (both flags,
|
||||
both readers)."""
|
||||
reader, core = reader_core
|
||||
point_config(_write_ini(
|
||||
tmp_path,
|
||||
f"[default]\nsecurity_level = normal\n{flag_key} = {raw_value}\n",
|
||||
))
|
||||
|
||||
cfg = core.read_config()
|
||||
|
||||
assert flag_key in cfg, (
|
||||
f"RED: {reader} read_config() does not register {flag_key!r}"
|
||||
)
|
||||
assert cfg[flag_key] is expected, (
|
||||
f"{reader}: {flag_key} = {raw_value!r} must read {expected}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SC-21 — write_config round-trip (write list registration, both writers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sc21_write_config_round_trips_both_flags(
|
||||
reader_core, point_config, tmp_path):
|
||||
"""SC-21: both flags true in cached config -> write_config -> reset the
|
||||
reader's cached_config -> read_config -> both keys persist as True.
|
||||
|
||||
Failure mode this guards: keys registered in the read dict but NOT in
|
||||
the write_config persistence list — the value would silently drop on
|
||||
the next config save (spec §2 F3/F4 dual-anchor rule, residual risk R2)."""
|
||||
reader, core = reader_core
|
||||
ini = _write_ini(
|
||||
tmp_path,
|
||||
"[default]\nsecurity_level = normal\n"
|
||||
"allow_git_url_install = true\nallow_pip_install = true\n",
|
||||
)
|
||||
point_config(ini)
|
||||
|
||||
loaded = core.get_config()
|
||||
for key in FLAG_KEYS:
|
||||
assert loaded.get(key) is True, (
|
||||
f"RED: {reader} get_config() did not load {key!r}=True "
|
||||
"from staged config.ini"
|
||||
)
|
||||
|
||||
core.write_config()
|
||||
|
||||
raw = ini.read_text(encoding="utf-8")
|
||||
for key in FLAG_KEYS:
|
||||
assert key in raw, (
|
||||
f"{reader}: write_config() dropped {key!r} — key missing from "
|
||||
"the write_config persistence list (spec §3 reader registration)"
|
||||
)
|
||||
|
||||
_reset_cache(core) # per-reader cache reset REQUIRED before re-read
|
||||
cfg = core.read_config()
|
||||
for key in FLAG_KEYS:
|
||||
assert cfg.get(key) is True, (
|
||||
f"{reader}: {key} did not survive the write/read round-trip"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SC-22 — cached_config staleness: restart-only activation (reader arm)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("flag_key", FLAG_KEYS)
|
||||
def test_sc22_flag_edit_without_restart_stays_stale(
|
||||
reader_core, point_config, tmp_path, flag_key):
|
||||
"""SC-22: process running with cached flag=false -> config.ini edited to
|
||||
flag=true WITHOUT restart -> the reader still serves False (module-level
|
||||
cached_config; identical semantics to security_level today, MM §1.5).
|
||||
Clearing the cache (= restart) then serves True."""
|
||||
reader, core = reader_core
|
||||
ini = _write_ini(
|
||||
tmp_path,
|
||||
f"[default]\nsecurity_level = normal\n{flag_key} = false\n",
|
||||
)
|
||||
point_config(ini)
|
||||
|
||||
first = core.get_config()
|
||||
assert first.get(flag_key) is False, (
|
||||
f"RED: {reader} get_config() does not register {flag_key!r}"
|
||||
)
|
||||
|
||||
# Edit config.ini on disk — NO cache reset (no restart).
|
||||
_write_ini(
|
||||
tmp_path,
|
||||
f"[default]\nsecurity_level = normal\n{flag_key} = true\n",
|
||||
)
|
||||
|
||||
stale = core.get_config()
|
||||
assert stale.get(flag_key) is False, (
|
||||
f"{reader}: {flag_key} hot-reloaded — activation MUST be "
|
||||
"restart-only (cached_config, spec §3)"
|
||||
)
|
||||
|
||||
_reset_cache(core) # simulate restart
|
||||
fresh = core.get_config()
|
||||
assert fresh.get(flag_key) is True, (
|
||||
f"{reader}: {flag_key}=true not visible after cache reset (restart)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SC-29 — exception-fallback path carries the new keys as False
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("breakage", ["missing_file", "no_default_section"])
|
||||
def test_sc29_fallback_path_returns_false_for_both_flags(
|
||||
reader_core, point_config, tmp_path, breakage):
|
||||
"""SC-29: NO config.ini present / config.ini WITHOUT [default] section ->
|
||||
read_config's exception-fallback dict returns False for BOTH new keys
|
||||
(fallback dicts are the third registration anchor per reader,
|
||||
spec §2 F3/F4)."""
|
||||
reader, core = reader_core
|
||||
if breakage == "missing_file":
|
||||
point_config(tmp_path / "does-not-exist" / "config.ini")
|
||||
else:
|
||||
point_config(_write_ini(tmp_path, "[other]\nsomething = 1\n"))
|
||||
|
||||
cfg = core.read_config()
|
||||
|
||||
for key in FLAG_KEYS:
|
||||
assert key in cfg, (
|
||||
f"RED: {reader} exception-fallback dict does not carry {key!r} "
|
||||
"(goal265-spec.md §3 'Reader registration' — fallback anchor)"
|
||||
)
|
||||
assert cfg[key] is False, (
|
||||
f"{reader}: fallback {key} must be False (secure by default)"
|
||||
)
|
||||
@@ -0,0 +1,256 @@
|
||||
"""[goal265 step4] Out-of-scope GUARD tests + structural frontend-copy
|
||||
assertion for the dedicated install flags GOAL.
|
||||
|
||||
Two populations in this module (intentionally different RED/GREEN states):
|
||||
|
||||
1. GUARD rows (SC-25C, SC-26, SC-27, SC-28) — regression-intent '=' rows:
|
||||
they assert the out-of-scope FREEZE (goal265-spec.md §5) and are expected
|
||||
to PASS both TODAY and AFTER Step 6. A failure at any point means the
|
||||
implementation leaked outside the locked scope.
|
||||
|
||||
2. SC-23 frontend arm — Δ row, EXPECTED TO FAIL TODAY (RED): the
|
||||
js/common.js 403 error copy must name the responsible flag after Step 6
|
||||
(spec §1.4, Q2 in scope). Do NOT weaken it to pass against today's code.
|
||||
|
||||
Placement per spec §4 rows 4-5 ('existing glob suite extension or unit' /
|
||||
'structural — copy-string assertion'): authored as a NEW unit/structural
|
||||
module so the pre-existing suite stays untouched (Step-4 acceptance bar (d));
|
||||
layout follows the Step-2 hand-off recommendation
|
||||
(goal265-scenarios.md §6: test_install_flags_guards.py).
|
||||
|
||||
Functional rows import the GLOB copy of the gate matrix
|
||||
(comfyui_manager/glob/utils/security_utils.py) with the lightweight runtime
|
||||
stubs from tests/_install_flags_testutil.py; the legacy copy of the same
|
||||
matrix is exercised end-to-end by tests/e2e/test_e2e_secgate_legacy_flags.py
|
||||
(SC-25A/B) under a real server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _install_flags_testutil import ( # noqa: E402
|
||||
REPO_ROOT,
|
||||
import_glob_security_utils,
|
||||
)
|
||||
|
||||
GLOB_SERVER_PATH = os.path.join(
|
||||
REPO_ROOT, "comfyui_manager", "glob", "manager_server.py")
|
||||
CM_CLI_PATH = os.path.join(REPO_ROOT, "cm_cli", "__main__.py")
|
||||
COMMON_JS_PATH = os.path.join(REPO_ROOT, "comfyui_manager", "js", "common.js")
|
||||
|
||||
FLAG_KEYS = ("allow_git_url_install", "allow_pip_install")
|
||||
|
||||
|
||||
def _read(path: str) -> str:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _extract_js_function(source: str, name: str) -> str:
|
||||
"""Slice an `export async function <name>` block out of common.js
|
||||
(up to the next exported/function declaration or EOF)."""
|
||||
m = re.search(
|
||||
rf"export\s+async\s+function\s+{re.escape(name)}\b.*?"
|
||||
r"(?=\nexport\s|\nfunction\s|\Z)",
|
||||
source,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m, f"could not locate function {name!r} in js/common.js"
|
||||
return m.group(0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SC-27 — glob route-table absence (guard, '=')
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sc27_glob_registers_no_git_url_or_pip_install_route():
|
||||
"""SC-27 (guard): glob/manager_server.py must register NO
|
||||
/v2/customnode/install/git_url or /v2/customnode/install/pip route —
|
||||
no new glob HTTP surface under this GOAL (Q4 settled; spec §5 item 5).
|
||||
Structural: route-decorator scan of the glob server source."""
|
||||
src = _read(GLOB_SERVER_PATH)
|
||||
for path in ("/v2/customnode/install/git_url",
|
||||
"/v2/customnode/install/pip"):
|
||||
pattern = rf"routes\.(post|get|put|delete)\(\s*[\"']{re.escape(path)}[\"']"
|
||||
assert not re.search(pattern, src), (
|
||||
f"SC-27: glob manager_server registers {path} — Q4 forbids a "
|
||||
"new glob surface for these capabilities under this GOAL"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SC-26 — cm-cli path stays ungated (guard, '=')
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sc26_cm_cli_install_path_has_no_gate():
|
||||
"""SC-26 (guard): the cm-cli install path (install_node ->
|
||||
core.gitclone_install, cm_cli/__main__.py) is a local operator tool —
|
||||
it must consult NEITHER the security_level gate NOR the new flags
|
||||
(spec §5 item 4: cm-cli untouched). Static source assertion — no clone
|
||||
is executed."""
|
||||
src = _read(CM_CLI_PATH)
|
||||
assert "is_allowed_security_level" not in src, (
|
||||
"SC-26: cm_cli/__main__.py grew a security_level gate — cm-cli "
|
||||
"must stay ungated under this GOAL"
|
||||
)
|
||||
for key in FLAG_KEYS:
|
||||
assert key not in src, (
|
||||
f"SC-26: cm_cli/__main__.py consults {key!r} — the dedicated "
|
||||
"flags must not gate the local operator CLI"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SC-25C — glob comfyui_switch_version stays high+ security_level-coupled
|
||||
# (guard, '=') — structural arm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sc25c_glob_switch_version_keeps_high_plus_gate():
|
||||
"""SC-25C (guard, structural arm): the glob comfyui_switch_version
|
||||
handler keeps its is_allowed_security_level('high+') guard and does not
|
||||
consult the new flags (spec §5 item 1). The functional arm (high+ denies
|
||||
at sl=normal regardless of flags) is test_sc25c_sc28_* below."""
|
||||
src = _read(GLOB_SERVER_PATH)
|
||||
m = re.search(
|
||||
r"@routes\.post\(\s*[\"']/v2/comfyui_manager/comfyui_switch_version[\"']\s*\)"
|
||||
r".*?(?=\n@routes\.|\Z)",
|
||||
src,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m, "SC-25C: comfyui_switch_version route not found in glob server"
|
||||
handler = m.group(0)
|
||||
assert re.search(
|
||||
r"is_allowed_security_level\(\s*[\"']high\+[\"']", handler), (
|
||||
"SC-25C: comfyui_switch_version no longer carries the "
|
||||
"is_allowed_security_level('high+') guard — out-of-scope gate "
|
||||
"changed (spec §5 freeze item 1)"
|
||||
)
|
||||
for key in FLAG_KEYS:
|
||||
assert key not in handler, (
|
||||
f"SC-25C: comfyui_switch_version consults {key!r} — the new "
|
||||
"flags must not affect this surface"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SC-28 (+ SC-25C functional arm) — security_level matrix untouched
|
||||
# (guard, '=')
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Frozen truth table of is_allowed_security_level at loopback listen,
|
||||
# network_mode=public (verified against legacy/manager_server.py:97-122 and
|
||||
# glob/utils/security_utils.py:22-48 as of base 01799f8c). The new flags
|
||||
# must leave every cell unchanged.
|
||||
_EXPECTED_MATRIX = {
|
||||
# (level, security_level): allowed
|
||||
("block", "weak"): False,
|
||||
("block", "normal"): False,
|
||||
("high+", "weak"): True,
|
||||
("high+", "normal-"): True,
|
||||
("high+", "normal"): False,
|
||||
("high+", "strong"): False,
|
||||
("high", "weak"): True,
|
||||
("high", "normal-"): True,
|
||||
("high", "normal"): False,
|
||||
("high", "strong"): False,
|
||||
("middle+", "weak"): True,
|
||||
("middle+", "normal-"): True,
|
||||
("middle+", "normal"): True,
|
||||
("middle+", "strong"): False,
|
||||
("middle", "weak"): True,
|
||||
("middle", "normal-"): True,
|
||||
("middle", "normal"): True,
|
||||
("middle", "strong"): False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags_value", [True, False],
|
||||
ids=["flags-on", "flags-off"])
|
||||
def test_sc25c_sc28_security_level_matrix_unchanged_by_flags(
|
||||
monkeypatch, flags_value):
|
||||
"""SC-28 (guard): the is_allowed_security_level truth table for the
|
||||
non-target levels (middle/middle+/high/high+/block) is byte-identical
|
||||
whether both new flags are true or false — security_level semantics
|
||||
untouched (spec §5 item 2; MM §3).
|
||||
|
||||
SC-25C (functional arm): the ('high+', 'normal') -> False cell is the
|
||||
switch_version deny — it must hold for BOTH flag combinations.
|
||||
|
||||
Exercises the GLOB copy of the matrix (importable without a ComfyUI
|
||||
runtime); config access is monkeypatched at the consumer module so no
|
||||
real config.ini is read."""
|
||||
su = import_glob_security_utils()
|
||||
|
||||
fake_config = {
|
||||
"network_mode": "public",
|
||||
"allow_git_url_install": flags_value,
|
||||
"allow_pip_install": flags_value,
|
||||
}
|
||||
current_level = {"value": "normal"}
|
||||
|
||||
def fake_get_config():
|
||||
return {**fake_config, "security_level": current_level["value"]}
|
||||
|
||||
monkeypatch.setattr(su.core, "get_config", fake_get_config)
|
||||
monkeypatch.setattr(su.args, "listen", "127.0.0.1")
|
||||
|
||||
observed = {}
|
||||
for (level, sl) in _EXPECTED_MATRIX:
|
||||
current_level["value"] = sl
|
||||
observed[(level, sl)] = su.is_allowed_security_level(level)
|
||||
|
||||
mismatches = {
|
||||
cell: (got, _EXPECTED_MATRIX[cell])
|
||||
for cell, got in observed.items()
|
||||
if got is not _EXPECTED_MATRIX[cell]
|
||||
}
|
||||
assert not mismatches, (
|
||||
f"SC-28(flags={flags_value}): security_level matrix drifted — "
|
||||
f"{{cell: (got, expected)}} = {mismatches!r}. The dedicated-flag "
|
||||
"GOAL must not alter is_allowed_security_level semantics."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SC-23 frontend arm — js/common.js error copy names the flag (Δ — RED today)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSc23FrontendCopy:
|
||||
"""SC-23 (frontend arm, structural — spec §1.4 / Q2 in scope): on a
|
||||
non-200 install response, the user-visible copy must name the
|
||||
responsible flag, replacing the 'security level' framing.
|
||||
Copy-string assertion on js/common.js — not an executed-browser test
|
||||
(goal265-scenarios.md §6 note). EXPECTED TO FAIL TODAY (RED)."""
|
||||
|
||||
def test_sc23_install_pip_error_copy_names_pip_flag(self):
|
||||
"""install_pip error branch (~:213 call site) names
|
||||
allow_pip_install and drops the security-level framing."""
|
||||
block = _extract_js_function(_read(COMMON_JS_PATH), "install_pip")
|
||||
assert "allow_pip_install" in block, (
|
||||
"SC-23: js/common.js install_pip error copy does not name "
|
||||
"allow_pip_install (spec §1.4 frontend copy contract)"
|
||||
)
|
||||
assert "security level" not in block.lower(), (
|
||||
"SC-23: js/common.js install_pip still carries the misleading "
|
||||
"'security level' framing"
|
||||
)
|
||||
|
||||
def test_sc23_install_git_url_error_copy_names_git_flag(self):
|
||||
"""install_via_git_url error branch (~:248 call site) names
|
||||
allow_git_url_install and drops the security-level framing."""
|
||||
block = _extract_js_function(
|
||||
_read(COMMON_JS_PATH), "install_via_git_url")
|
||||
assert "allow_git_url_install" in block, (
|
||||
"SC-23: js/common.js install_via_git_url error copy does not "
|
||||
"name allow_git_url_install (spec §1.4 frontend copy contract)"
|
||||
)
|
||||
assert "security level" not in block.lower(), (
|
||||
"SC-23: js/common.js install_via_git_url still carries the "
|
||||
"misleading 'security level' framing"
|
||||
)
|
||||
Reference in New Issue
Block a user